Merge commit 'abe5413638043a52e4fcbb5f16aefd14e76bf96c' into glitch-soc/merge-upstream

This commit is contained in:
Claire committed 2025-08-22 22:52:55 +02:00
commit 5cac4dfb5c
127 files changed
+908 -646

No files matched your search

@@ -15,10 +15,8 @@ import { missingAltTextModal } from 'mastodon/initial_state';
import AutosuggestInput from 'mastodon/components/autosuggest_input';
import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
import { Button } from 'mastodon/components/button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container';
import { countableText } from '../util/counter';
@@ -32,6 +30,7 @@ import { ReplyIndicator } from './reply_indicator';
import { UploadForm } from './upload_form';
import { Warning } from './warning';
import { ComposeQuotedStatus } from './quoted_post';
import { VisibilityButton } from './visibility_button';
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';
@@ -260,7 +259,7 @@ class ComposeForm extends ImmutablePureComponent {
<EditIndicator />
<div className='compose-form__dropdowns'>
<PrivacyDropdownContainer disabled={this.props.isEditing} />
<VisibilityButton disabled={this.props.isEditing} />
<LanguageDropdown />
</div>
@@ -1,15 +1,17 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { Map } from 'immutable';
import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
import { QuotedStatus } from '@/mastodon/components/status_quoted';
import { useAppSelector } from '@/mastodon/store';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
export const ComposeQuotedStatus: FC = () => {
const quotedStatusId = useAppSelector(
(state) => state.compose.get('quoted_status_id') as string | null,
);
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
const quote = useMemo(
() =>
quotedStatusId
@@ -20,8 +22,17 @@ export const ComposeQuotedStatus: FC = () => {
: null,
[quotedStatusId],
);
const dispatch = useAppDispatch();
const handleQuoteCancel = useCallback(() => {
dispatch(quoteComposeCancel());
}, [dispatch]);
if (!quote) {
return null;
}
return <QuotedStatus quote={quote} contextType='compose' />;
return (
<QuotedStatus
quote={quote}
onQuoteCancel={!isEditing ? handleQuoteCancel : undefined}
/>
);
};
@@ -0,0 +1,148 @@
import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { changeComposeVisibility } from '@/mastodon/actions/compose';
import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed';
import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { Icon } from '@/mastodon/components/icon';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import { isFeatureEnabled } from '@/mastodon/utils/environment';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import type { VisibilityModalCallback } from '../../ui/components/visibility_modal';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import { messages as privacyMessages } from './privacy_dropdown';
const messages = defineMessages({
anyone_quote: {
id: 'privacy.quote.anyone',
defaultMessage: '{visibility}, anyone can quote',
},
limited_quote: {
id: 'privacy.quote.limited',
defaultMessage: '{visibility}, quotes limited',
},
disabled_quote: {
id: 'privacy.quote.disabled',
defaultMessage: '{visibility}, quotes disabled',
},
});
interface PrivacyDropdownProps {
disabled?: boolean;
}
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
if (!isFeatureEnabled('outgoing_quotes')) {
return <PrivacyDropdownContainer {...props} />;
}
return <PrivacyModalButton {...props} />;
};
const visibilityOptions = {
public: {
icon: 'globe',
iconComponent: PublicIcon,
value: 'public',
text: privacyMessages.public_short,
},
unlisted: {
icon: 'unlock',
iconComponent: QuietTimeIcon,
value: 'unlisted',
text: privacyMessages.unlisted_short,
},
private: {
icon: 'lock',
iconComponent: LockIcon,
value: 'private',
text: privacyMessages.private_short,
},
direct: {
icon: 'at',
iconComponent: AlternateEmailIcon,
value: 'direct',
text: privacyMessages.direct_short,
},
};
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
const intl = useIntl();
const { visibility, quotePolicy } = useAppSelector((state) => ({
visibility: state.compose.get('privacy') as StatusVisibility,
quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy,
}));
const { icon, iconComponent } = useMemo(() => {
const option = visibilityOptions[visibility];
return { icon: option.icon, iconComponent: option.iconComponent };
}, [visibility]);
const text = useMemo(() => {
const visibilityText = intl.formatMessage(
visibilityOptions[visibility].text,
);
if (visibility === 'private' || visibility === 'direct') {
return visibilityText;
}
if (quotePolicy === 'nobody') {
return intl.formatMessage(messages.disabled_quote, {
visibility: visibilityText,
});
}
if (quotePolicy !== 'public') {
return intl.formatMessage(messages.limited_quote, {
visibility: visibilityText,
});
}
return intl.formatMessage(messages.anyone_quote, {
visibility: visibilityText,
});
}, [quotePolicy, visibility, intl]);
const dispatch = useAppDispatch();
const handleChange: VisibilityModalCallback = useCallback(
(newVisibility, newQuotePolicy) => {
if (newVisibility !== visibility) {
dispatch(changeComposeVisibility(newVisibility));
}
if (newQuotePolicy !== quotePolicy) {
dispatch(setComposeQuotePolicy(newQuotePolicy));
}
},
[dispatch, quotePolicy, visibility],
);
const handleOpen = useCallback(() => {
dispatch(
openModal({
modalType: 'COMPOSE_PRIVACY',
modalProps: { onChange: handleChange },
}),
);
}, [dispatch, handleChange]);
return (
<button
type='button'
title={intl.formatMessage(privacyMessages.change_privacy)}
onClick={handleOpen}
disabled={disabled}
className={classNames('dropdown-button')}
>
<Icon id={icon} icon={iconComponent} />
<span className='dropdown-button__label'>{text}</span>
</button>
);
};
@@ -45,7 +45,7 @@ const getAccounts = createSelector(
const getStatus = makeGetStatus();
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
export const Conversation = ({ conversation, scrollKey }) => {
const id = conversation.get('id');
const unread = conversation.get('unread');
const lastStatusId = conversation.get('last_status');
@@ -110,14 +110,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
dispatch(deleteConversation(id));
}, [dispatch, id]);
const handleHotkeyMoveUp = useCallback(() => {
onMoveUp(id);
}, [id, onMoveUp]);
const handleHotkeyMoveDown = useCallback(() => {
onMoveDown(id);
}, [id, onMoveDown]);
const handleConversationMute = useCallback(() => {
if (lastStatus.get('muted')) {
dispatch(unmuteStatus(lastStatus.get('id')));
@@ -161,8 +153,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
const handlers = {
reply: handleReply,
open: handleClick,
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
toggleHidden: handleShowMore,
};
@@ -224,6 +214,4 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};
@@ -10,20 +10,6 @@ import ScrollableList from 'mastodon/components/scrollable_list';
import { Conversation } from './conversation';
const focusChild = (node, index, alignTop) => {
const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (alignTop && node.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
};
export const ConversationsList = ({ scrollKey, ...other }) => {
const listRef = useRef();
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
@@ -32,16 +18,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
const dispatch = useDispatch();
const lastStatusId = conversations.last()?.get('last_status');
const handleMoveUp = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
focusChild(listRef.current.node, elementIndex, true);
}, [listRef, conversations]);
const handleMoveDown = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
focusChild(listRef.current.node, elementIndex, false);
}, [listRef, conversations]);
const debouncedLoadMore = useMemo(() => debounce(id => {
dispatch(expandConversations({ maxId: id }));
}, 300, { leading: true }), [dispatch]);
@@ -58,8 +34,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
<Conversation
key={item.get('id')}
conversation={item}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
scrollKey={scrollKey}
/>
))}
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { useEffect, useCallback, useRef, useState, useId } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
@@ -19,6 +19,7 @@ import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { domain } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
@@ -56,9 +57,7 @@ const messages = defineMessages({
},
});
const Source: React.FC<{
id: ApiSuggestionSourceJSON;
}> = ({ id }) => {
const Source: React.FC<{ id: ApiSuggestionSourceJSON }> = ({ id }) => {
const intl = useIntl();
let label, hint;
@@ -168,10 +167,11 @@ const Card: React.FC<{
const DISMISSIBLE_ID = 'home/follow-suggestions';
export const InlineFollowSuggestions: React.FC<{
hidden?: boolean;
}> = ({ hidden }) => {
export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
hidden,
}) => {
const intl = useIntl();
const uniqueId = useId();
const dispatch = useAppDispatch();
const suggestions = useAppSelector((state) => state.suggestions.items);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
@@ -257,9 +257,14 @@ export const InlineFollowSuggestions: React.FC<{
}
return (
<div className='inline-follow-suggestions'>
<div
role='group'
aria-labelledby={uniqueId}
className='inline-follow-suggestions focusable'
tabIndex={-1}
>
<div className='inline-follow-suggestions__header'>
<h3>
<h3 id={uniqueId}>
<FormattedMessage
id='follow_suggestions.who_to_follow'
defaultMessage='Who to follow'
@@ -288,13 +293,17 @@ export const InlineFollowSuggestions: React.FC<{
ref={bodyRef}
onScroll={handleScroll}
>
{suggestions.map((suggestion) => (
<Card
key={suggestion.account_id}
id={suggestion.account_id}
sources={suggestion.sources}
/>
))}
{isLoading ? (
<LoadingIndicator />
) : (
suggestions.map((suggestion) => (
<Card
key={suggestion.account_id}
id={suggestion.account_id}
sources={suggestion.sources}
/>
))
)}
</div>
{canScrollLeft && (
@@ -83,13 +83,17 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr>
<tr>
<td><kbd>up</kbd>, <kbd>k</kbd></td>
<td><kbd>k</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
</tr>
<tr>
<td><kbd>down</kbd>, <kbd>j</kbd></td>
<td><kbd>j</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
</tr>
<tr>
<td><kbd>l</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.load_more' defaultMessage='Focus "Load more" button' /></td>
</tr>
<tr>
<td><kbd>1</kbd>-<kbd>9</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td>
@@ -57,8 +57,6 @@ class Notification extends ImmutablePureComponent {
static propTypes = {
notification: ImmutablePropTypes.map.isRequired,
hidden: PropTypes.bool,
onMoveUp: PropTypes.func.isRequired,
onMoveDown: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
@@ -73,16 +71,6 @@ class Notification extends ImmutablePureComponent {
...WithRouterPropTypes,
};
handleMoveUp = () => {
const { notification, onMoveUp } = this.props;
onMoveUp(notification.get('id'));
};
handleMoveDown = () => {
const { notification, onMoveDown } = this.props;
onMoveDown(notification.get('id'));
};
handleOpen = () => {
const { notification } = this.props;
@@ -128,8 +116,6 @@ class Notification extends ImmutablePureComponent {
mention: this.handleMention,
open: this.handleOpen,
openProfile: this.handleOpenProfile,
moveUp: this.handleMoveUp,
moveDown: this.handleMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
};
}
@@ -180,8 +166,6 @@ class Notification extends ImmutablePureComponent {
id={notification.get('status')}
withDismiss
hidden={this.props.hidden}
onMoveDown={this.handleMoveDown}
onMoveUp={this.handleMoveUp}
contextType='notifications'
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
@@ -31,21 +31,6 @@ const messages = defineMessages({
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
});
const selectChild = (ref, index, alignTop) => {
const container = ref.current.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (alignTop && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!alignTop && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
};
export const NotificationRequest = ({ multiColumn, params: { id } }) => {
const columnRef = useRef();
const intl = useIntl();
@@ -74,16 +59,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
dispatch(acceptNotificationRequest({ id }));
}, [dispatch, id]);
const handleMoveUp = useCallback(id => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
selectChild(columnRef, elementIndex, true);
}, [columnRef, notifications]);
const handleMoveDown = useCallback(id => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
selectChild(columnRef, elementIndex, false);
}, [columnRef, notifications]);
useEffect(() => {
dispatch(fetchNotificationRequest({ id }));
}, [dispatch, id]);
@@ -146,8 +121,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
key={item.get('id')}
notification={item}
accountId={item.get('account')}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))}
</ScrollableList>
@@ -24,9 +24,7 @@ import { NotificationUpdate } from './notification_update';
export const NotificationGroup: React.FC<{
notificationGroupId: NotificationGroupModel['group_key'];
unread: boolean;
onMoveUp: (groupId: string) => void;
onMoveDown: (groupId: string) => void;
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
}> = ({ notificationGroupId, unread }) => {
const notificationGroup = useAppSelector((state) =>
state.notificationGroups.groups.find(
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
@@ -42,14 +40,6 @@ export const NotificationGroup: React.FC<{
const handlers = useMemo(
() => ({
moveUp: () => {
onMoveUp(notificationGroupId);
},
moveDown: () => {
onMoveDown(notificationGroupId);
},
openProfile: () => {
if (accountId) dispatch(navigateToProfile(accountId));
},
@@ -58,7 +48,7 @@ export const NotificationGroup: React.FC<{
if (accountId) dispatch(mentionComposeById(accountId));
},
}),
[dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown],
[dispatch, accountId],
);
if (!notificationGroup || notificationGroup.type === 'gap') return null;
@@ -99,29 +99,6 @@ export const Notifications: React.FC<{
const columnRef = useRef<ColumnRef>(null);
const selectChild = useCallback((index: number, alignTop: boolean) => {
const container = columnRef.current?.node as HTMLElement | undefined;
if (!container) return;
const element = container.querySelector<HTMLElement>(
`article:nth-of-type(${index + 1}) .focusable`,
);
if (element) {
if (alignTop && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (
!alignTop &&
container.scrollTop + container.clientHeight <
element.offsetTop + element.offsetHeight
) {
element.scrollIntoView(false);
}
element.focus();
}
}, []);
// Keep track of mounted components for unread notification handling
useEffect(() => {
void dispatch(mountNotifications());
@@ -187,28 +164,6 @@ export const Notifications: React.FC<{
columnRef.current?.scrollTop();
}, []);
const handleMoveUp = useCallback(
(id: string) => {
const elementIndex =
notifications.findIndex(
(item) => item.type !== 'gap' && item.group_key === id,
) - 1;
selectChild(elementIndex, true);
},
[notifications, selectChild],
);
const handleMoveDown = useCallback(
(id: string) => {
const elementIndex =
notifications.findIndex(
(item) => item.type !== 'gap' && item.group_key === id,
) + 1;
selectChild(elementIndex, false);
},
[notifications, selectChild],
);
const handleMarkAsRead = useCallback(() => {
dispatch(markNotificationsAsRead());
void dispatch(submitMarkers({ immediate: true }));
@@ -241,8 +196,6 @@ export const Notifications: React.FC<{
<NotificationGroup
key={item.group_key}
notificationGroupId={item.group_key}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
unread={
lastReadId !== '0' &&
!!item.page_max_id &&
@@ -251,15 +204,7 @@ export const Notifications: React.FC<{
/>
),
);
}, [
notifications,
isLoading,
hasMore,
lastReadId,
handleLoadGap,
handleMoveUp,
handleMoveDown,
]);
}, [notifications, isLoading, hasMore, lastReadId, handleLoadGap]);
const prepend = (
<>
@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
@@ -15,7 +15,6 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
import { Hotkeys } from 'mastodon/components/hotkeys';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import ScrollContainer from 'mastodon/containers/scroll_container';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
@@ -57,6 +56,7 @@ import {
translateStatus,
undoStatusTranslation,
} from '../../actions/statuses';
import { setStatusQuotePolicy } from '../../actions/statuses_typed';
import ColumnHeader from '../../components/column_header';
import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import { StatusQuoteManager } from '../../components/status_quoted';
@@ -266,8 +266,14 @@ class Status extends ImmutablePureComponent {
};
handleQuotePolicyChange = (status) => {
const statusId = status.get('id');
const { dispatch } = this.props;
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId: status.get('id') } }));
const handleChange = (_, quotePolicy) => {
dispatch(
setStatusQuotePolicy({ policy: quotePolicy, statusId }),
);
}
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
};
handleEditClick = (status) => {
@@ -390,15 +396,6 @@ class Status extends ImmutablePureComponent {
this.props.dispatch(unblockDomain(domain));
};
handleHotkeyMoveUp = () => {
this.handleMoveUp(this.props.status.get('id'));
};
handleHotkeyMoveDown = () => {
this.handleMoveDown(this.props.status.get('id'));
};
handleHotkeyReply = e => {
e.preventDefault();
this.handleReplyClick(this.props.status);
@@ -433,54 +430,6 @@ class Status extends ImmutablePureComponent {
this.handleTranslate(this.props.status);
};
handleMoveUp = id => {
const { status, ancestorsIds, descendantsIds } = this.props;
if (id === status.get('id')) {
this._selectChild(ancestorsIds.length - 1, true);
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.length + index, true);
} else {
this._selectChild(index - 1, true);
}
}
};
handleMoveDown = id => {
const { status, ancestorsIds, descendantsIds } = this.props;
if (id === status.get('id')) {
this._selectChild(ancestorsIds.length + 1, false);
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.length + index + 2, false);
} else {
this._selectChild(index + 1, false);
}
}
};
_selectChild (index, align_top) {
const container = this.node;
const element = container.querySelectorAll('.focusable')[index];
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}
renderChildren (list, ancestors) {
const { params: { statusId } } = this.props;
@@ -488,8 +437,6 @@ class Status extends ImmutablePureComponent {
<StatusQuoteManager
key={id}
id={id}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType='thread'
previousId={i > 0 ? list[i - 1] : undefined}
nextId={list[i + 1] || (ancestors && statusId)}
@@ -596,8 +543,6 @@ class Status extends ImmutablePureComponent {
}
const handlers = {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
@@ -620,7 +565,7 @@ class Status extends ImmutablePureComponent {
/>
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
<div className={classNames('scrollable', { fullscreen })} ref={this.setContainerRef}>
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
{ancestors}
<Hotkeys handlers={handlers}>
@@ -1,25 +1,25 @@
import { forwardRef, useCallback, useId, useMemo } from 'react';
import { forwardRef, useCallback, useId, useMemo, useState } from 'react';
import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { changeComposeVisibility } from '@/mastodon/actions/compose';
import { setStatusQuotePolicy } from '@/mastodon/actions/statuses_typed';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import { isQuotePolicy } from '@/mastodon/api_types/quotes';
import { isStatusVisibility } from '@/mastodon/api_types/statuses';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { Button } from '@/mastodon/components/button';
import { Dropdown } from '@/mastodon/components/dropdown';
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
import { IconButton } from '@/mastodon/components/icon_button';
import { messages as privacyMessages } from '@/mastodon/features/compose/components/privacy_dropdown';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import type { BaseConfirmationModalProps } from './confirmation_modals/confirmation_modal';
@@ -43,13 +43,26 @@ const messages = defineMessages({
},
});
export type VisibilityModalCallback = (
visibility: StatusVisibility,
quotePolicy: ApiQuotePolicy,
) => void;
interface VisibilityModalProps extends BaseConfirmationModalProps {
statusId: string;
statusId?: string;
onChange: VisibilityModalCallback;
}
const selectStatusPolicy = createAppSelector(
[(state) => state.statuses, (_state, statusId: string) => statusId],
(statuses, statusId) => {
[
(state) => state.statuses,
(_state, statusId?: string) => statusId,
(state) => state.compose.get('quote_policy') as ApiQuotePolicy,
],
(statuses, statusId, composeQuotePolicy) => {
if (!statusId) {
return composeQuotePolicy;
}
const status = statuses.get(statusId);
if (!status) {
return 'public';
@@ -78,23 +91,25 @@ const selectStatusPolicy = createAppSelector(
export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ onClose, statusId }, ref) => {
({ onClose, onChange, statusId }, _ref) => {
const intl = useIntl();
const currentVisibility = useAppSelector(
(state) =>
(state.statuses.getIn([statusId, 'visibility'], 'public') as
| StatusVisibility
| undefined) ?? 'public',
const currentVisibility = useAppSelector((state) =>
statusId
? ((state.statuses.getIn([statusId, 'visibility'], 'public') as
| StatusVisibility
| undefined) ?? 'public')
: (state.compose.get('privacy') as StatusVisibility),
);
const currentQuotePolicy = useAppSelector((state) =>
selectStatusPolicy(state, statusId),
);
const [visibility, setVisibility] = useState(currentVisibility);
const [quotePolicy, setQuotePolicy] = useState(currentQuotePolicy);
const disableVisibility = !!statusId;
const disableQuotePolicy =
currentVisibility === 'private' || currentVisibility === 'direct';
const isSaving = useAppSelector(
(state) =>
state.statuses.getIn([statusId, 'isSavingQuotePolicy']) === true,
);
visibility === 'private' || visibility === 'direct';
const visibilityItems = useMemo<SelectItem<StatusVisibility>[]>(
() => [
@@ -102,21 +117,30 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
value: 'public',
text: intl.formatMessage(privacyMessages.public_short),
meta: intl.formatMessage(privacyMessages.public_long),
icon: 'globe',
iconComponent: PublicIcon,
},
{
value: 'unlisted',
text: intl.formatMessage(privacyMessages.unlisted_short),
meta: intl.formatMessage(privacyMessages.unlisted_long),
extra: intl.formatMessage(privacyMessages.unlisted_extra),
icon: 'unlock',
iconComponent: QuietTimeIcon,
},
{
value: 'private',
text: intl.formatMessage(privacyMessages.private_short),
meta: intl.formatMessage(privacyMessages.private_long),
icon: 'lock',
iconComponent: LockIcon,
},
{
value: 'direct',
text: intl.formatMessage(privacyMessages.direct_short),
meta: intl.formatMessage(privacyMessages.direct_long),
icon: 'at',
iconComponent: AlternateEmailIcon,
},
],
[intl],
@@ -133,25 +157,20 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
[intl],
);
const dispatch = useAppDispatch();
const handleVisibilityChange = useCallback(
(value: string) => {
// Published statuses cannot change visibility.
if (statusId) {
return;
}
dispatch(changeComposeVisibility(value));
},
[dispatch, statusId],
);
const handleQuotePolicyChange = useCallback(
(value: string) => {
if (isQuotePolicy(value) && !disableQuotePolicy) {
void dispatch(setStatusQuotePolicy({ policy: value, statusId }));
}
},
[disableQuotePolicy, dispatch, statusId],
);
const handleVisibilityChange = useCallback((value: string) => {
if (isStatusVisibility(value)) {
setVisibility(value);
}
}, []);
const handleQuotePolicyChange = useCallback((value: string) => {
if (isQuotePolicy(value)) {
setQuotePolicy(value);
}
}, []);
const handleSave = useCallback(() => {
onChange(visibility, quotePolicy);
onClose();
}, [onChange, onClose, visibility, quotePolicy]);
const privacyDropdownId = useId();
const quoteDropdownId = useId();
@@ -192,7 +211,7 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
<label
htmlFor={privacyDropdownId}
className={classNames('visibility-dropdown__label', {
disabled: isSaving || !!statusId,
disabled: disableVisibility,
})}
>
<FormattedMessage
@@ -203,10 +222,10 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
<Dropdown
items={visibilityItems}
classPrefix='visibility-dropdown'
current={currentVisibility}
current={visibility}
onChange={handleVisibilityChange}
title={intl.formatMessage(privacyMessages.change_privacy)}
disabled={isSaving || !!statusId}
disabled={disableVisibility}
id={privacyDropdownId}
/>
{!!statusId && (
@@ -222,7 +241,7 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
<label
htmlFor={quoteDropdownId}
className={classNames('visibility-dropdown__label', {
disabled: disableQuotePolicy || isSaving,
disabled: disableQuotePolicy,
})}
>
<FormattedMessage
@@ -234,17 +253,28 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
items={quoteItems}
onChange={handleQuotePolicyChange}
classPrefix='visibility-dropdown'
current={currentQuotePolicy}
current={quotePolicy}
title={intl.formatMessage(messages.buttonTitle)}
disabled={disableQuotePolicy || isSaving}
disabled={disableQuotePolicy}
id={quoteDropdownId}
/>
<QuotePolicyHelper
policy={currentQuotePolicy}
visibility={currentVisibility}
/>
<QuotePolicyHelper policy={quotePolicy} visibility={visibility} />
</label>
</div>
<div className='dialog-modal__content__actions'>
<Button onClick={onClose} secondary>
<FormattedMessage
id='confirmation_modal.cancel'
defaultMessage='Cancel'
/>
</Button>
<Button onClick={handleSave}>
<FormattedMessage
id='visibility_modal.save'
defaultMessage='Save'
/>
</Button>
</div>
</div>
</div>
);
+30 -12
View File
@@ -77,6 +77,7 @@ import {
AccountFeatured,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
import { focusColumn, getFocusedItemIndex, focusItemSibling } from './util/focusUtils';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
@@ -446,20 +447,34 @@ class UI extends PureComponent {
};
handleHotkeyFocusColumn = e => {
const index = (e.key * 1) + 1; // First child is drawer, skip that
const column = this.node.querySelector(`.column:nth-child(${index})`);
if (!column) return;
const container = column.querySelector('.scrollable');
focusColumn({index: e.key * 1});
};
if (container) {
const status = container.querySelector('.focusable');
handleHotkeyLoadMore = () => {
document.querySelector('.load-more')?.focus();
};
if (status) {
if (container.scrollTop > status.offsetTop) {
status.scrollIntoView(true);
}
status.focus();
}
handleMoveUp = () => {
const currentItemIndex = getFocusedItemIndex();
if (currentItemIndex === -1) {
focusColumn({
index: 1,
focusItem: 'first-visible',
});
} else {
focusItemSibling(currentItemIndex, -1);
}
};
handleMoveDown = () => {
const currentItemIndex = getFocusedItemIndex();
if (currentItemIndex === -1) {
focusColumn({
index: 1,
focusItem: 'first-visible',
});
} else {
focusItemSibling(currentItemIndex, 1);
}
};
@@ -542,6 +557,9 @@ class UI extends PureComponent {
forceNew: this.handleHotkeyForceNew,
toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
focusColumn: this.handleHotkeyFocusColumn,
focusLoadMore: this.handleHotkeyLoadMore,
moveDown: this.handleMoveDown,
moveUp: this.handleMoveUp,
back: this.handleHotkeyBack,
goToHome: this.handleHotkeyGoToHome,
goToNotifications: this.handleHotkeyGoToNotifications,
@@ -0,0 +1,132 @@
import initialState from '@/mastodon/initial_state';
interface FocusColumnOptions {
index?: number;
focusItem?: 'first' | 'first-visible';
}
/**
* Move focus to the column of the passed index (1-based).
* Can either focus the topmost item or the first one in the viewport
*/
export function focusColumn({
index = 1,
focusItem = 'first',
}: FocusColumnOptions = {}) {
// Skip the leftmost drawer in multi-column mode
const indexOffset = initialState?.meta.advanced_layout ? 1 : 0;
const column = document.querySelector(
`.column:nth-child(${index + indexOffset})`,
);
if (!column) return;
const container = column.querySelector('.scrollable');
if (!container) return;
let itemToFocus: HTMLElement | null = null;
if (focusItem === 'first-visible') {
const focusableItems = Array.from(
container.querySelectorAll<HTMLElement>(
'.focusable:not(.status__quote .focusable)',
),
);
const viewportHeight =
window.innerHeight || document.documentElement.clientHeight;
// Find first item visible in the viewport
itemToFocus =
focusableItems.find((item) => {
const { top } = item.getBoundingClientRect();
return top >= 0 && top < viewportHeight;
}) ?? null;
} else {
itemToFocus = container.querySelector('.focusable');
}
if (itemToFocus) {
if (container.scrollTop > itemToFocus.offsetTop) {
itemToFocus.scrollIntoView(true);
}
itemToFocus.focus();
}
}
/**
* Get the index of the currently focused item in one of our item lists
*/
export function getFocusedItemIndex() {
const focusedElement = document.activeElement;
const itemList = focusedElement?.closest('.item-list');
if (!focusedElement || !itemList) {
return -1;
}
let focusedItem: HTMLElement | null = null;
if (focusedElement.parentElement === itemList) {
focusedItem = focusedElement as HTMLElement;
} else {
focusedItem = focusedElement.closest('.item-list > *');
}
if (!focusedItem) return -1;
const items = Array.from(itemList.children);
return items.indexOf(focusedItem);
}
/**
* Focus the item next to the one with the provided index
*/
export function focusItemSibling(
index: number,
direction: 1 | -1,
scrollThreshold = 62,
) {
const focusedElement = document.activeElement;
const itemList = focusedElement?.closest('.item-list');
const siblingItem = itemList?.querySelector<HTMLElement>(
// :nth-child uses 1-based indexing
`.item-list > :nth-child(${index + 1 + direction})`,
);
if (!siblingItem) {
return;
}
// If sibling element is empty, we skip it
if (siblingItem.matches(':empty')) {
focusItemSibling(index + direction, direction);
return;
}
// Check if the sibling is a post or a 'follow suggestions' widget
let targetElement = siblingItem.querySelector<HTMLElement>('.focusable');
// Otherwise, check if the item is a 'load more' button.
if (!targetElement && siblingItem.matches('.load-more')) {
targetElement = siblingItem;
}
if (targetElement) {
const elementRect = targetElement.getBoundingClientRect();
const isFullyVisible =
elementRect.top >= scrollThreshold &&
elementRect.bottom <= window.innerHeight;
if (!isFullyVisible) {
targetElement.scrollIntoView({
block: direction === 1 ? 'start' : 'center',
});
}
targetElement.focus();
}
}