mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 15:16:40 -05:00
Composer redesign: Q/A pass (#40452)
This commit is contained in:
@@ -16,6 +16,7 @@ import { openModal } from './modal';
|
||||
import { updateTimeline } from './timelines';
|
||||
import { insertStatusIntoAccountTimelines } from './timelines_typed';
|
||||
import { isRedesignEnabled } from '../utils/environment';
|
||||
import { requestComposerFocus } from '../reducers/slices/composer';
|
||||
|
||||
/** @type {AbortController | undefined} */
|
||||
let fetchComposeSuggestionsAccountsController;
|
||||
@@ -126,6 +127,12 @@ export function replyCompose(status) {
|
||||
});
|
||||
|
||||
ensureComposeIsVisible(getState);
|
||||
|
||||
if (isRedesignEnabled()) {
|
||||
const text = getState().getIn(['compose', 'text'], '');
|
||||
// Preselect any mentions past the first, mirroring the reply text's leading `@user `.
|
||||
dispatch(requestComposerFocus({ start: text.search(/\s/) + 1, end: text.length }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,6 +168,11 @@ export const focusCompose = (defaultText = '', caretStart = false) => (dispatch,
|
||||
});
|
||||
|
||||
ensureComposeIsVisible(getState);
|
||||
|
||||
if (isRedesignEnabled()) {
|
||||
const position = caretStart ? 0 : getState().getIn(['compose', 'text'], '').length;
|
||||
dispatch(requestComposerFocus({ start: position, end: position }));
|
||||
}
|
||||
};
|
||||
|
||||
export function mentionCompose(account) {
|
||||
@@ -171,6 +183,11 @@ export function mentionCompose(account) {
|
||||
});
|
||||
|
||||
ensureComposeIsVisible(getState);
|
||||
|
||||
if (isRedesignEnabled()) {
|
||||
const position = getState().getIn(['compose', 'text'], '').length;
|
||||
dispatch(requestComposerFocus({ start: position, end: position }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,6 +205,11 @@ export function directCompose(account) {
|
||||
});
|
||||
|
||||
ensureComposeIsVisible(getState);
|
||||
|
||||
if (isRedesignEnabled()) {
|
||||
const position = getState().getIn(['compose', 'text'], '').length;
|
||||
dispatch(requestComposerFocus({ start: position, end: position }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -654,7 +676,9 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||
|
||||
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
|
||||
// the suggestions are dismissed and the cursor moves forward.
|
||||
if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
|
||||
const inserted = suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0;
|
||||
|
||||
if (inserted) {
|
||||
dispatch({
|
||||
type: COMPOSE_SUGGESTION_SELECT,
|
||||
position: startPosition,
|
||||
@@ -671,6 +695,11 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
if (isRedesignEnabled() && path.length === 1 && path[0] === 'text') {
|
||||
const caretPosition = startPosition + (inserted ? completion.length : token.length) + 1;
|
||||
dispatch(requestComposerFocus({ start: caretPosition, end: caretPosition }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export type MenuCardProps<As extends React.ElementType> = PolymorphicProps<
|
||||
elevation?: 1 | 2;
|
||||
maxWidth?: number | string;
|
||||
style?: React.CSSProperties;
|
||||
popover?: React.HTMLAttributes<As>['popover'];
|
||||
},
|
||||
As
|
||||
>;
|
||||
|
||||
@@ -150,8 +150,10 @@ export const Menu: React.FC<MenuProps> = ({
|
||||
if (shouldClose === false) return;
|
||||
|
||||
setIsMenuOpen(false);
|
||||
triggerElement?.focus();
|
||||
}, [triggerElement, onClose]);
|
||||
if (listElement?.contains(document.activeElement)) {
|
||||
triggerElement?.focus();
|
||||
}
|
||||
}, [listElement, triggerElement, onClose]);
|
||||
|
||||
const toggleMenu = isMenuOpen ? closeMenu : openMenu;
|
||||
|
||||
|
||||
@@ -130,4 +130,7 @@
|
||||
|
||||
.itemTrailingContent {
|
||||
margin-inline-start: auto;
|
||||
|
||||
// Add additional space after the text.
|
||||
padding-inline-start: var(--space-xs);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from '@/mastodon/selectors/statuses';
|
||||
import type { AppDispatch } from '@/mastodon/store';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { isRedesignEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -580,7 +581,7 @@ function getMenuItems({
|
||||
),
|
||||
action: onStatusInteraction('mute'),
|
||||
});
|
||||
if (interactions.editQuotePolicy) {
|
||||
if (interactions.editQuotePolicy && !isRedesignEnabled()) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.quotePolicyChange),
|
||||
action: onStatusInteraction('editQuotePolicy'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, use, useCallback, useMemo } from 'react';
|
||||
import { createContext, createElement, use, useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
@@ -12,11 +12,13 @@ import { toggleStatusSpoilers } from '@/mastodon/actions/statuses';
|
||||
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
import type {
|
||||
AccountStatusShape,
|
||||
ExpandedStatusShape,
|
||||
StatusShape,
|
||||
} from '@/mastodon/models/status';
|
||||
import { selectStatusFilters } from '@/mastodon/selectors/filters';
|
||||
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
|
||||
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||
|
||||
import { FOCUS_TARGET } from '../navigation_focus_target';
|
||||
|
||||
@@ -284,3 +286,27 @@ export function useHandlersForStatus(
|
||||
hrefToMention,
|
||||
});
|
||||
}
|
||||
|
||||
export const onStatusLinksDisabled: OnElementHandler<AccountStatusShape> = (
|
||||
element,
|
||||
{ key, href },
|
||||
children,
|
||||
status,
|
||||
) => {
|
||||
// If this is a paragraph with just a link and it matches the card, don't add it.
|
||||
if (
|
||||
element instanceof HTMLParagraphElement &&
|
||||
element.children.length === 1 &&
|
||||
element.firstChild instanceof HTMLAnchorElement &&
|
||||
element.firstChild.href === status.card?.url
|
||||
) {
|
||||
return null;
|
||||
} else if (element instanceof HTMLAnchorElement) {
|
||||
if (href === status.card?.url) {
|
||||
return null;
|
||||
}
|
||||
// Just use createElement instead of making the whole file JSX.
|
||||
return createElement('strong', { key: key as string }, children);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
selectStatusLoadingState,
|
||||
} from '@/mastodon/selectors/statuses';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||
|
||||
import { Avatar } from '../avatar';
|
||||
import { Button } from '../button/redesign';
|
||||
@@ -44,6 +43,7 @@ import { Icon } from '../icon';
|
||||
import { PopoverMenuCard } from '../menu/card';
|
||||
import { RelativeTimestamp } from '../relative_timestamp';
|
||||
|
||||
import { onStatusLinksDisabled } from './hooks';
|
||||
import { StatusImage } from './image';
|
||||
import classes from './quote.module.scss';
|
||||
|
||||
@@ -212,7 +212,7 @@ const QuotedStatusBody: React.FC<{
|
||||
htmlString={status.translation?.contentHtml ?? status.contentHtml}
|
||||
extraEmojis={status.emojis}
|
||||
lang={status.translation?.language ?? status.language}
|
||||
onElement={onStatusLinks}
|
||||
onElement={onStatusLinksDisabled}
|
||||
extraArgs={status}
|
||||
/>
|
||||
|
||||
@@ -326,29 +326,6 @@ const QuotedStatusLink: React.FC<{ status: AccountStatusShape }> = ({
|
||||
return <CardBody>{link}</CardBody>;
|
||||
};
|
||||
|
||||
const onStatusLinks: OnElementHandler<AccountStatusShape> = (
|
||||
element,
|
||||
{ key, href },
|
||||
children,
|
||||
status,
|
||||
) => {
|
||||
// If this is a paragraph with just a link and it matches the card, don't add it.
|
||||
if (
|
||||
element instanceof HTMLParagraphElement &&
|
||||
element.children.length === 1 &&
|
||||
element.firstChild instanceof HTMLAnchorElement &&
|
||||
element.firstChild.href === status.card?.url
|
||||
) {
|
||||
return null;
|
||||
} else if (element instanceof HTMLAnchorElement) {
|
||||
if (href === status.card?.url) {
|
||||
return null;
|
||||
}
|
||||
return <strong key={key as string}>{children}</strong>;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function useQuoteError({
|
||||
quoted_status: quoteId,
|
||||
state: quoteState,
|
||||
|
||||
@@ -88,10 +88,9 @@ export const ComposeFooter: React.FC<{ onEmojiPick: OnEmojiPick }> = ({
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{type !== 'message' && (
|
||||
{type !== 'message' && type !== 'replyPrivate' ? (
|
||||
<FormattedMessage id='compose.publish' defaultMessage='Publish' />
|
||||
)}
|
||||
{type === 'message' && (
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='compose.message.publish'
|
||||
defaultMessage='Send'
|
||||
|
||||
@@ -16,8 +16,8 @@ import { ToggleButton } from '@/mastodon/components/button/redesign';
|
||||
import { TextInputField } from '@/mastodon/components/form_fields/redesign';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import {
|
||||
focusComposerTextarea,
|
||||
getComposerTextarea,
|
||||
requestComposerFocus,
|
||||
submitComposer,
|
||||
} from '@/mastodon/reducers/slices/composer';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
@@ -143,9 +143,9 @@ function useComposeHandlers(redirectOnSuccess?: boolean) {
|
||||
const isSensitive = useAppSelector((state) => !!state.compose.get('spoiler'));
|
||||
useEffect(() => {
|
||||
if (!isSensitive) {
|
||||
focusComposerTextarea();
|
||||
dispatch(requestComposerFocus());
|
||||
}
|
||||
}, [isSensitive]);
|
||||
}, [isSensitive, dispatch]);
|
||||
|
||||
const onSensitiveChange = useCallback(() => {
|
||||
dispatch(changeComposeSpoilerness());
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
ModalTitle,
|
||||
} from '@/mastodon/components/modal_shell/redesign';
|
||||
import {
|
||||
focusComposerTextarea,
|
||||
openNewComposer,
|
||||
requestComposerFocus,
|
||||
resetComposer,
|
||||
} from '@/mastodon/reducers/slices/composer';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
@@ -34,7 +34,7 @@ const ComposerModalCancelConfirm: React.FC<{ openNew?: boolean }> = ({
|
||||
dispatch(
|
||||
closeModal({ modalType: 'COMPOSER_DRAFT_DELETE', ignoreFocus: false }),
|
||||
);
|
||||
focusComposerTextarea(true);
|
||||
dispatch(requestComposerFocus());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -49,14 +49,14 @@ const ComposerModalSwitch: React.FC = () => {
|
||||
/>
|
||||
|
||||
<ModalActions>
|
||||
<Button size='sm' onClick={handleBack}>
|
||||
<Button onClick={handleBack}>
|
||||
<FormattedMessage
|
||||
id='compose.switch_modal.back'
|
||||
defaultMessage='Back'
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button size='sm' variant='solid' onClick={handleContinue}>
|
||||
<Button variant='solid' onClick={handleContinue}>
|
||||
<FormattedMessage
|
||||
id='compose.switch_modal.continue'
|
||||
defaultMessage='Continue'
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Avatar } from '@/mastodon/components/avatar';
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
|
||||
import { useHandlersForStatus } from '@/mastodon/components/status/hooks';
|
||||
import { onStatusLinksDisabled } from '@/mastodon/components/status/hooks';
|
||||
import { statusLink } from '@/mastodon/components/status/utils';
|
||||
import { selectAccountStatus } from '@/mastodon/selectors/statuses';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
@@ -16,8 +17,6 @@ export const ComposeReply: React.FC = () => {
|
||||
);
|
||||
const status = useAppSelector((state) => selectAccountStatus(state, replyId));
|
||||
|
||||
const htmlHandlers = useHandlersForStatus(status);
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
@@ -30,26 +29,29 @@ export const ComposeReply: React.FC = () => {
|
||||
className={classes.replyAvatar}
|
||||
withLink
|
||||
/>
|
||||
|
||||
<LinkedDisplayName
|
||||
displayProps={{ account: status.account, variant: 'simple' }}
|
||||
/>
|
||||
|
||||
<span className={classes.replyTime}>
|
||||
·
|
||||
<Link to={`/@${status.account.acct}/${status.id}`}>
|
||||
•
|
||||
<Link to={statusLink(status)}>
|
||||
<RelativeTimestamp timestamp={status.created_at} />
|
||||
</Link>
|
||||
</span>
|
||||
</figcaption>
|
||||
|
||||
<EmojiHTML
|
||||
as='blockquote'
|
||||
cite={status.uri}
|
||||
htmlString={status.translation?.contentHtml ?? status.contentHtml}
|
||||
extraEmojis={status.emojis}
|
||||
className={classes.replyText}
|
||||
lang={status.translation?.language ?? status.language}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
<Link to={statusLink(status)} className={classes.replyText}>
|
||||
<EmojiHTML
|
||||
as='blockquote'
|
||||
cite={status.uri}
|
||||
htmlString={status.translation?.contentHtml ?? status.contentHtml}
|
||||
extraEmojis={status.emojis}
|
||||
lang={status.translation?.language ?? status.language}
|
||||
onElement={onStatusLinksDisabled}
|
||||
/>
|
||||
</Link>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,19 +59,46 @@ export const selectComposeCharsCount = createAppSelector(
|
||||
},
|
||||
);
|
||||
|
||||
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 const selectComposeCanSubmit = createAppSelector(
|
||||
[
|
||||
(state) => !!state.compose.get('is_submitting'),
|
||||
(state) => !!state.compose.get('is_uploading'),
|
||||
(state) => !!state.compose.get('is_changing_upload'),
|
||||
selectComposeHasAttachments,
|
||||
selectComposeCharsCount,
|
||||
],
|
||||
(isSubmitting, isUploading, isChangingUpload, { text, max }) =>
|
||||
(
|
||||
isSubmitting,
|
||||
isUploading,
|
||||
isChangingUpload,
|
||||
{ hasAttachments, hasPoll, quotedStatusId },
|
||||
{ text, max },
|
||||
) =>
|
||||
!isSubmitting &&
|
||||
!isUploading &&
|
||||
!isChangingUpload &&
|
||||
text.trim().length <= max &&
|
||||
text.trim().length > 0,
|
||||
(hasAttachments || hasPoll || quotedStatusId || text.trim().length > 0),
|
||||
);
|
||||
|
||||
export const selectComposeMentions = createAppSelector(
|
||||
@@ -83,7 +110,7 @@ export const selectComposeMentions = createAppSelector(
|
||||
(accountsMap, text, localDomain) => {
|
||||
const accounts = new Set<string>();
|
||||
const potentialAccounts = text.matchAll(
|
||||
/@(?<username>[a-zA-Z0-9_.-]+)(?<domain>@[a-zA-Z0-9_.-]+)?/g,
|
||||
/(?<!:\/\/[^\s]+)@(?<username>[a-zA-Z0-9_.-]+)(?<domain>@[a-zA-Z0-9_.-]+)?/g,
|
||||
);
|
||||
for (const match of potentialAccounts) {
|
||||
const { username, domain } = match.groups ?? {};
|
||||
@@ -163,26 +190,6 @@ export const selectFrequentlyUsedEmoji = createAppSelector(
|
||||
},
|
||||
);
|
||||
|
||||
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<
|
||||
TAttachment extends ApiMediaAttachmentJSON = ApiMediaAttachmentJSON,
|
||||
> = TAttachment & {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
background: var(--color-bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-md);
|
||||
z-index: 1; // To cover the sidebar
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-top: var(--space-xs);
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
align-items: center;
|
||||
@@ -215,11 +216,6 @@ textarea.textarea {
|
||||
.reply {
|
||||
border-inline-start: 0.5px solid var(--color-border-primary);
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
.replyAccount {
|
||||
display: flex;
|
||||
margin-bottom: var(--space-2xs);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
@@ -227,6 +223,17 @@ textarea.textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.replyAccount {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2xs);
|
||||
|
||||
> a,
|
||||
> span {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.replyAvatar {
|
||||
margin-inline-end: var(--space-xs);
|
||||
border-radius: var(--radius-round);
|
||||
@@ -241,17 +248,13 @@ textarea.textarea {
|
||||
@include mixins.line-clamp(2);
|
||||
@include mixins.type-body-compact;
|
||||
|
||||
display: block;
|
||||
|
||||
// Round two lines to the nearest 5px.
|
||||
max-height: round(2lh, 5px);
|
||||
|
||||
a {
|
||||
color: var(--color-text-brand);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
@@ -21,8 +21,8 @@ import { TextArea } from '@/mastodon/components/form_fields';
|
||||
import { normalizeKey } from '@/mastodon/components/hotkeys/utils';
|
||||
import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor';
|
||||
import {
|
||||
clearComposerFocusRequest,
|
||||
COMPOSER_TEXTAREA_ID,
|
||||
focusComposerTextarea,
|
||||
} from '@/mastodon/reducers/slices/composer';
|
||||
import {
|
||||
createAppSelector,
|
||||
@@ -93,7 +93,6 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
dispatch(
|
||||
selectComposeSuggestion(tokenStart, token, suggestion, ['text']),
|
||||
);
|
||||
focusComposerTextarea(true);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
@@ -105,6 +104,22 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
const suggestions = useAppSelector(selectSuggestions);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Applies a focus/selection requested from elsewhere (e.g. reply, mention) once this textarea exists,
|
||||
// which also covers it not being mounted yet when the request was made (it's lazy-loaded).
|
||||
const pendingFocus = useAppSelector((state) => state.composer.pendingFocus);
|
||||
useEffect(() => {
|
||||
if (!pendingFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { selection } = pendingFocus;
|
||||
if (selection) {
|
||||
textAreaRef.current?.setSelectionRange(selection.start, selection.end);
|
||||
}
|
||||
textAreaRef.current?.focus({ preventScroll: true });
|
||||
dispatch(clearComposerFocusRequest());
|
||||
}, [pendingFocus, dispatch]);
|
||||
|
||||
const {
|
||||
onTextChange,
|
||||
focus,
|
||||
|
||||
@@ -27,8 +27,9 @@
|
||||
@media (width >= #{variables.$mobile-menu-breakpoint}) {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
min-height: 520px;
|
||||
min-height: 270px;
|
||||
height: auto;
|
||||
max-height: calc(100vh - (var(--space-md) * 2));
|
||||
min-width: 300px;
|
||||
width: calc(100vw - var(--space-md) * 2);
|
||||
max-width: 500px;
|
||||
@@ -49,6 +50,7 @@
|
||||
width: min(320px, calc(100vw - var(--space-md) * 2));
|
||||
padding: var(--space-md);
|
||||
margin: var(--space-md);
|
||||
border-radius: var(--radius-xl);
|
||||
|
||||
@media (width < #{variables.$mobile-menu-breakpoint}) {
|
||||
bottom: var(--mobile-bottom-nav-height);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MenuItem,
|
||||
} from '@/mastodon/components/menu';
|
||||
import { MenuCard } from '@/mastodon/components/menu/card';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import { openNewComposer } from '@/mastodon/reducers/slices/composer';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { isRedesignEnabled } from '@/mastodon/utils/environment';
|
||||
@@ -69,7 +70,9 @@ export const ComposeRedesignButton: React.FC<{
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!isRedesignEnabled()) {
|
||||
const { signedIn } = useIdentity();
|
||||
|
||||
if (!isRedesignEnabled() || !signedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
|
||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||
import { CaretIcon } from '@/mastodon/components/button/redesign';
|
||||
import { Button, CaretIcon } from '@/mastodon/components/button/redesign';
|
||||
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
|
||||
import {
|
||||
Menu,
|
||||
@@ -38,6 +38,7 @@ export const ComposeVisibility: React.FC<{ className?: string }> = ({
|
||||
className,
|
||||
}) => {
|
||||
const privacy = useAppSelector(selectComposePrivacy);
|
||||
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -47,7 +48,12 @@ export const ComposeVisibility: React.FC<{ className?: string }> = ({
|
||||
description='Before button that indicates who a post is for (Public, Followers, mentioned people)'
|
||||
/>
|
||||
<Menu>
|
||||
<MenuTrigger size='sm' trailingIcon={CaretIcon}>
|
||||
<MenuTrigger
|
||||
as={Button}
|
||||
size='sm'
|
||||
trailingIcon={CaretIcon}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<ComposeVisibilityButtonText privacy={privacy} />
|
||||
</MenuTrigger>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSlice, isAction } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
changeCompose,
|
||||
@@ -36,18 +37,13 @@ export function getComposerTextarea() {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Focuses on the composer textarea.
|
||||
* @param defer Waits before focusing. Useful if the composer may not be focusable immediately.
|
||||
*/
|
||||
export function focusComposerTextarea(defer = false) {
|
||||
if (defer) {
|
||||
requestAnimationFrame(() => {
|
||||
getComposerTextarea()?.focus();
|
||||
});
|
||||
} else {
|
||||
getComposerTextarea()?.focus();
|
||||
}
|
||||
export interface ComposerTextareaSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface PendingFocus {
|
||||
selection: ComposerTextareaSelection | null;
|
||||
}
|
||||
|
||||
type DisplayState = 'hidden' | 'showing' | 'minimized';
|
||||
@@ -56,10 +52,12 @@ export type ComposeType = 'post' | 'message' | 'reply' | 'replyPrivate';
|
||||
|
||||
interface ComposerState {
|
||||
displayState: DisplayState;
|
||||
pendingFocus: PendingFocus | null;
|
||||
}
|
||||
|
||||
const initialState: ComposerState = {
|
||||
displayState: 'hidden',
|
||||
pendingFocus: null,
|
||||
};
|
||||
|
||||
const composerSlice = createSlice({
|
||||
@@ -76,6 +74,15 @@ const composerSlice = createSlice({
|
||||
hideComposer(state) {
|
||||
state.displayState = 'hidden';
|
||||
},
|
||||
requestFocus(
|
||||
state,
|
||||
action: PayloadAction<ComposerTextareaSelection | undefined>,
|
||||
) {
|
||||
state.pendingFocus = { selection: action.payload ?? null };
|
||||
},
|
||||
clearPendingFocus(state) {
|
||||
state.pendingFocus = null;
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addMatcher(
|
||||
@@ -98,6 +105,11 @@ const composerSlice = createSlice({
|
||||
|
||||
export const composer = composerSlice.reducer;
|
||||
|
||||
export const {
|
||||
requestFocus: requestComposerFocus,
|
||||
clearPendingFocus: clearComposerFocusRequest,
|
||||
} = composerSlice.actions;
|
||||
|
||||
export const minimizeComposerToggle = createAppThunk(
|
||||
(_arg, { dispatch, getState }) => {
|
||||
dispatch(composerSlice.actions.minimizeComposerToggle());
|
||||
@@ -172,12 +184,13 @@ export const openNewComposer = createAppThunk(
|
||||
dispatch(directCompose(account));
|
||||
} else {
|
||||
dispatch(changeComposeVisibility('direct'));
|
||||
dispatch(requestComposerFocus());
|
||||
}
|
||||
} else if (payload.type === 'reply') {
|
||||
dispatch(replyComposeById(payload.toStatusId));
|
||||
} else {
|
||||
dispatch(requestComposerFocus());
|
||||
}
|
||||
|
||||
focusComposerTextarea(true);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
// Utility
|
||||
--color-bg-inverted: var(--color-grey-950);
|
||||
--color-bg-overlay-highlight: var(--color-bg-highlight); // legacy
|
||||
--color-bg-overlay: var(--color-bg-primary); // legacy
|
||||
--color-bg-overlay: var(--color-black); // legacy
|
||||
--color-bg-media-base: var(--color-black); // legacy
|
||||
--color-bg-media: #{utils.css-alpha(var(--color-bg-media-base), 65%)}; // legacy
|
||||
--color-bg-disabled: var(--color-grey-400); // legacy
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
ForwardRefExoticComponent,
|
||||
} from 'react';
|
||||
|
||||
import type { DistributedOmit } from 'type-fest';
|
||||
|
||||
// This complicated type file is based on the following posts:
|
||||
// - https://www.tsteele.dev/posts/react-polymorphic-forwardref
|
||||
// - https://www.kripod.dev/blog/behind-the-as-prop-polymorphism-done-well/
|
||||
@@ -83,5 +85,8 @@ export type PolymorphicProps<
|
||||
As extends React.ElementType,
|
||||
> = {
|
||||
as?: As;
|
||||
} & Omit<React.ComponentPropsWithRef<As>, keyof AdditionalProps | 'as'> &
|
||||
} & DistributedOmit<
|
||||
React.ComponentPropsWithRef<As>,
|
||||
keyof AdditionalProps | 'as'
|
||||
> &
|
||||
AdditionalProps;
|
||||
|
||||
Reference in New Issue
Block a user