Merge remote-tracking branch 'origin/develop' into ptr-fix

This commit is contained in:
Alex Gleason
2022-07-14 13:01:18 -05:00
34 changed files with 760 additions and 916 deletions

View File

@@ -1,17 +1,17 @@
import Portal from '@reach/portal';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import AutosuggestAccount from '../features/compose/components/autosuggest_account';
import { isRtl } from '../rtl';
import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestEmoji, { Emoji } from './autosuggest_emoji';
const textAtCursorMatchesToken = (str, caretPosition) => {
import type { List as ImmutableList } from 'immutable';
const textAtCursorMatchesToken = (str: string, caretPosition: number) => {
let word;
const left = str.slice(0, caretPosition).search(/\S+$/);
@@ -36,25 +36,28 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
}
};
export default class AutosuggestTextarea extends ImmutablePureComponent {
interface IAutosuggesteTextarea {
id?: string,
value: string,
suggestions: ImmutableList<string>,
disabled: boolean,
placeholder: string,
onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void,
onSuggestionsClearRequested: () => void,
onSuggestionsFetchRequested: (token: string | number) => void,
onChange: React.ChangeEventHandler<HTMLTextAreaElement>,
onKeyUp: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement>,
onPaste: (files: FileList) => void,
autoFocus: boolean,
onFocus: () => void,
onBlur?: () => void,
condensed?: boolean,
}
static propTypes = {
value: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
condensed: PropTypes.bool,
};
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {
textarea: HTMLTextAreaElement | null = null;
static defaultProps = {
autoFocus: true,
@@ -68,7 +71,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
tokenStart: 0,
};
onChange = (e) => {
onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) {
@@ -82,7 +85,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
this.props.onChange(e);
}
onKeyDown = (e) => {
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
@@ -91,7 +94,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
return;
}
if (e.which === 229 || e.isComposing) {
if (e.which === 229 || (e as any).isComposing) {
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
return;
@@ -100,7 +103,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
switch (e.key) {
case 'Escape':
if (suggestions.size === 0 || suggestionsHidden) {
document.querySelector('.ui').parentElement.focus();
document.querySelector('.ui')?.parentElement?.focus();
} else {
e.preventDefault();
this.setState({ suggestionsHidden: true });
@@ -156,14 +159,14 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}
}
onSuggestionClick = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
onSuggestionClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any);
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea.focus();
this.textarea?.focus();
}
shouldComponentUpdate(nextProps, nextState) {
shouldComponentUpdate(nextProps: IAutosuggesteTextarea, nextState: any) {
// Skip updating when only the lastToken changes so the
// cursor doesn't jump around due to re-rendering unnecessarily
const lastTokenUpdated = this.state.lastToken !== nextState.lastToken;
@@ -172,29 +175,29 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
if (lastTokenUpdated && !valueUpdated) {
return false;
} else {
return super.shouldComponentUpdate(nextProps, nextState);
return super.shouldComponentUpdate!(nextProps, nextState, undefined);
}
}
componentDidUpdate(prevProps, prevState) {
componentDidUpdate(prevProps: IAutosuggesteTextarea, prevState: any) {
const { suggestions } = this.props;
if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) {
this.setState({ suggestionsHidden: false });
}
}
setTextarea = (c) => {
setTextarea: React.Ref<HTMLTextAreaElement> = (c) => {
this.textarea = c;
}
onPaste = (e) => {
onPaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
}
renderSuggestion = (suggestion, i) => {
renderSuggestion = (suggestion: string | Emoji, i: number) => {
const { selectedSuggestion } = this.state;
let inner, key;
@@ -212,7 +215,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
return (
<div
role='button'
tabIndex='0'
tabIndex={0}
key={key}
data-index={i}
className={classNames({
@@ -272,7 +275,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
style={style as any}
aria-autocomplete='list'
/>
</label>
@@ -297,3 +300,5 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}
}
export default AutosuggestTextarea;

View File

@@ -18,7 +18,7 @@ let id = 0;
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
middleClick?: React.EventHandler<React.MouseEvent>,
text: string | JSX.Element,
text: string,
href?: string,
to?: string,
newTab?: boolean,

View File

@@ -1,39 +0,0 @@
/**
* ForkAwesomeIcon: renders a ForkAwesome icon.
* Full list: https://forkaweso.me/Fork-Awesome/icons/
* @module soapbox/components/fork_awesome_icon
* @see soapbox/components/icon
*/
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
export default class ForkAwesomeIcon extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
className: PropTypes.string,
fixedWidth: PropTypes.bool,
};
render() {
const { id, className, fixedWidth, ...other } = this.props;
// Use the Fork Awesome retweet icon, but change its alt
// tag. There is a common adblocker rule which hides elements with
// alt='retweet' unless the domain is twitter.com. This should
// change what screenreaders call it as well.
const alt = (id === 'retweet') ? 'repost' : id;
return (
<i
role='img'
alt={alt}
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
{...other}
/>
);
}
}

View File

@@ -0,0 +1,34 @@
/**
* ForkAwesomeIcon: renders a ForkAwesome icon.
* Full list: https://forkaweso.me/Fork-Awesome/icons/
* @module soapbox/components/fork_awesome_icon
* @see soapbox/components/icon
*/
import classNames from 'classnames';
import React from 'react';
export interface IForkAwesomeIcon extends React.HTMLAttributes<HTMLLIElement> {
id: string,
className?: string,
fixedWidth?: boolean,
}
const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth, ...rest }) => {
// Use the Fork Awesome retweet icon, but change its alt
// tag. There is a common adblocker rule which hides elements with
// alt='retweet' unless the domain is twitter.com. This should
// change what screenreaders call it as well.
// const alt = (id === 'retweet') ? 'repost' : id;
return (
<i
role='img'
// alt={alt}
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
{...rest}
/>
);
};
export default ForkAwesomeIcon;

View File

@@ -1,33 +0,0 @@
/**
* Icon: abstract icon class that can render icons from multiple sets.
* @module soapbox/components/icon
* @see soapbox/components/fork_awesome_icon
* @see soapbox/components/svg_icon
*/
import PropTypes from 'prop-types';
import React from 'react';
import ForkAwesomeIcon from './fork_awesome_icon';
import SvgIcon from './svg_icon';
export default class Icon extends React.PureComponent {
static propTypes = {
id: PropTypes.string,
src: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
className: PropTypes.string,
fixedWidth: PropTypes.bool,
};
render() {
const { id, src, fixedWidth, ...rest } = this.props;
if (src) {
return <SvgIcon src={src} {...rest} />;
} else {
return <ForkAwesomeIcon id={id} fixedWidth={fixedWidth} {...rest} />;
}
}
}

View File

@@ -0,0 +1,27 @@
/**
* Icon: abstract icon class that can render icons from multiple sets.
* @module soapbox/components/icon
* @see soapbox/components/fork_awesome_icon
* @see soapbox/components/svg_icon
*/
import React from 'react';
import ForkAwesomeIcon, { IForkAwesomeIcon } from './fork_awesome_icon';
import SvgIcon, { ISvgIcon } from './svg_icon';
export type IIcon = IForkAwesomeIcon | ISvgIcon;
const Icon: React.FC<IIcon> = (props) => {
if ((props as ISvgIcon).src) {
const { src, ...rest } = (props as ISvgIcon);
return <SvgIcon src={src} {...rest} />;
} else {
const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon);
return <ForkAwesomeIcon id={id} fixedWidth={fixedWidth} {...rest} />;
}
};
export default Icon;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import Icon from 'soapbox/components/icon';
import Icon, { IIcon } from 'soapbox/components/icon';
import { Counter } from 'soapbox/components/ui';
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
@@ -12,7 +12,7 @@ interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) => {
return (
<div className='relative'>
<Icon id={icon} {...rest} />
<Icon id={icon} {...rest as IIcon} />
{count > 0 && (
<i className='absolute -top-2 -right-2'>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { getSettings } from 'soapbox/actions/settings';
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
@@ -11,8 +11,20 @@ import SidebarNavigationLink from './sidebar-navigation-link';
import type { Menu } from 'soapbox/components/dropdown_menu';
const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' },
all: { id: 'tabs_bar.all', defaultMessage: 'All' },
fediverse: { id: 'tabs_bar.fediverse', defaultMessage: 'Fediverse' },
});
/** Desktop sidebar with links to different views in the app. */
const SidebarNavigation = () => {
const intl = useIntl();
const instance = useAppSelector((state) => state.instance);
const settings = useAppSelector((state) => getSettings(state));
const account = useOwnAccount();
@@ -30,7 +42,7 @@ const SidebarNavigation = () => {
if (account.locked || followRequestsCount > 0) {
menu.push({
to: '/follow_requests',
text: <FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' />,
text: intl.formatMessage(messages.follow_requests),
icon: require('@tabler/icons/user-plus.svg'),
count: followRequestsCount,
});
@@ -39,7 +51,7 @@ const SidebarNavigation = () => {
if (features.bookmarks) {
menu.push({
to: '/bookmarks',
text: <FormattedMessage id='column.bookmarks' defaultMessage='Bookmarks' />,
text: intl.formatMessage(messages.bookmarks),
icon: require('@tabler/icons/bookmark.svg'),
});
}
@@ -47,7 +59,7 @@ const SidebarNavigation = () => {
if (features.lists) {
menu.push({
to: '/lists',
text: <FormattedMessage id='column.lists' defaultMessage='Lists' />,
text: intl.formatMessage(messages.lists),
icon: require('@tabler/icons/list.svg'),
});
}
@@ -56,7 +68,7 @@ const SidebarNavigation = () => {
menu.push({
to: '/developers',
icon: require('@tabler/icons/code.svg'),
text: <FormattedMessage id='navigation.developers' defaultMessage='Developers' />,
text: intl.formatMessage(messages.developers),
});
}
@@ -64,7 +76,7 @@ const SidebarNavigation = () => {
menu.push({
to: '/soapbox/admin',
icon: require('@tabler/icons/dashboard.svg'),
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
text: intl.formatMessage(messages.dashboard),
count: dashboardCount,
});
}
@@ -78,7 +90,7 @@ const SidebarNavigation = () => {
menu.push({
to: '/timeline/local',
icon: features.federating ? require('@tabler/icons/users.svg') : require('@tabler/icons/world.svg'),
text: features.federating ? instance.title : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />,
text: features.federating ? instance.title : intl.formatMessage(messages.all),
});
}
@@ -86,7 +98,7 @@ const SidebarNavigation = () => {
menu.push({
to: '/timeline/fediverse',
icon: require('icons/fediverse.svg'),
text: <FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />,
text: intl.formatMessage(messages.fediverse),
});
}

View File

@@ -36,7 +36,7 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
/** ID of the timeline in Redux. */
timelineId?: string,
/** Whether to display a gap or border between statuses in the list. */
divideType: 'space' | 'border',
divideType?: 'space' | 'border',
}
/** Feed of statuses, built atop ScrollableList. */

View File

@@ -1,105 +0,0 @@
import throttle from 'lodash/throttle';
import PropTypes from 'prop-types';
import React from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import { CardHeader, CardTitle } from './ui';
const messages = defineMessages({
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
});
const mapDispatchToProps = (dispatch, { settings: Settings }) => {
return {
onOpenSettings() {
dispatch(openModal('COMPONENT', { component: Settings }));
},
};
};
export default @connect(undefined, mapDispatchToProps)
@injectIntl
@withRouter
class SubNavigation extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
message: PropTypes.string,
settings: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
onOpenSettings: PropTypes.func.isRequired,
history: PropTypes.object,
}
state = {
scrolled: false,
}
handleBackClick = () => {
if (window.history && window.history.length === 1) {
this.props.history.push('/');
} else {
this.props.history.goBack();
}
}
handleBackKeyUp = (e) => {
if (e.key === 'Enter') {
this.handleClick();
}
}
componentDidMount() {
this.attachScrollListener();
}
componentWillUnmount() {
this.detachScrollListener();
}
attachScrollListener() {
window.addEventListener('scroll', this.handleScroll);
}
detachScrollListener() {
window.removeEventListener('scroll', this.handleScroll);
}
handleScroll = throttle(() => {
if (this.node) {
const { offsetTop } = this.node;
if (offsetTop > 0) {
this.setState({ scrolled: true });
} else {
this.setState({ scrolled: false });
}
}
}, 150, { trailing: true });
handleOpenSettings = () => {
this.props.onOpenSettings();
}
setRef = c => {
this.node = c;
}
render() {
const { intl, message } = this.props;
return (
<CardHeader
aria-label={intl.formatMessage(messages.back)}
onBackClick={this.handleBackClick}
>
<CardTitle title={message} />
</CardHeader>
);
}
}

View File

@@ -0,0 +1,83 @@
// import throttle from 'lodash/throttle';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
// import { connect } from 'react-redux';
import { useHistory } from 'react-router-dom';
// import { openModal } from 'soapbox/actions/modals';
// import { useAppDispatch } from 'soapbox/hooks';
import { CardHeader, CardTitle } from './ui';
const messages = defineMessages({
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
});
interface ISubNavigation {
message: String,
settings?: React.ComponentType,
}
const SubNavigation: React.FC<ISubNavigation> = ({ message }) => {
const intl = useIntl();
// const dispatch = useAppDispatch();
const history = useHistory();
// const ref = useRef(null);
// const [scrolled, setScrolled] = useState(false);
// const onOpenSettings = () => {
// dispatch(openModal('COMPONENT', { component: Settings }));
// };
const handleBackClick = () => {
if (window.history && window.history.length === 1) {
history.push('/');
} else {
history.goBack();
}
};
// const handleBackKeyUp = (e) => {
// if (e.key === 'Enter') {
// handleClick();
// }
// }
// const handleOpenSettings = () => {
// onOpenSettings();
// }
// useEffect(() => {
// const handleScroll = throttle(() => {
// if (this.node) {
// const { offsetTop } = this.node;
// if (offsetTop > 0) {
// setScrolled(true);
// } else {
// setScrolled(false);
// }
// }
// }, 150, { trailing: true });
// window.addEventListener('scroll', handleScroll);
// return () => {
// window.removeEventListener('scroll', handleScroll);
// };
// }, []);
return (
<CardHeader
aria-label={intl.formatMessage(messages.back)}
onBackClick={handleBackClick}
>
<CardTitle title={message} />
</CardHeader>
);
};
export default SubNavigation;

View File

@@ -1,33 +0,0 @@
/**
* SvgIcon: abstact component to render SVG icons.
* @module soapbox/components/svg_icon
* @see soapbox/components/icon
*/
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
export default class SvgIcon extends React.PureComponent {
static propTypes = {
src: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
alt: PropTypes.string,
className: PropTypes.string,
};
render() {
const { src, className, alt, ...other } = this.props;
return (
<div
className={classNames('svg-icon', className)}
{...other}
>
<InlineSVG src={src} title={alt} loader={<></>} />
</div>
);
}
}

View File

@@ -0,0 +1,29 @@
/**
* SvgIcon: abstact component to render SVG icons.
* @module soapbox/components/svg_icon
* @see soapbox/components/icon
*/
import classNames from 'classnames';
import React from 'react';
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
export interface ISvgIcon extends React.HTMLAttributes<HTMLDivElement> {
src: string,
id?: string,
alt?: string,
className?: string,
}
const SvgIcon: React.FC<ISvgIcon> = ({ src, alt, className, ...rest }) => {
return (
<div
className={classNames('svg-icon', className)}
{...rest}
>
<InlineSVG src={src} title={alt} loader={<></>} />
</div>
);
};
export default SvgIcon;

View File

@@ -18,6 +18,8 @@ export interface IColumn {
withHeader?: boolean,
/** Extra class name for top <div> element. */
className?: string,
/** Ref forwarded to column. */
ref?: React.Ref<HTMLDivElement>
}
/** A backdrop for the main section of the UI. */