Merge remote-tracking branch 'origin/develop' into ptr-fix
This commit is contained in:
commit
4b94bc8b5d
|
@ -174,9 +174,9 @@ const excludeTypesFromFilter = (filter: string) => {
|
||||||
return allTypes.filterNot(item => item === filter).toJS();
|
return allTypes.filterNot(item => item === filter).toJS();
|
||||||
};
|
};
|
||||||
|
|
||||||
const noOp = () => {};
|
const noOp = () => new Promise(f => f(undefined));
|
||||||
|
|
||||||
const expandNotifications = ({ maxId }: Record<string, any> = {}, done = noOp) =>
|
const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => any = noOp) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
if (!isLoggedIn(getState)) return dispatch(noOp);
|
if (!isLoggedIn(getState)) return dispatch(noOp);
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import Portal from '@reach/portal';
|
import Portal from '@reach/portal';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Textarea from 'react-textarea-autosize';
|
import Textarea from 'react-textarea-autosize';
|
||||||
|
|
||||||
import AutosuggestAccount from '../features/compose/components/autosuggest_account';
|
import AutosuggestAccount from '../features/compose/components/autosuggest_account';
|
||||||
import { isRtl } from '../rtl';
|
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;
|
let word;
|
||||||
|
|
||||||
const left = str.slice(0, caretPosition).search(/\S+$/);
|
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 = {
|
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {
|
||||||
value: PropTypes.string,
|
|
||||||
suggestions: ImmutablePropTypes.list,
|
textarea: HTMLTextAreaElement | null = null;
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
|
@ -68,7 +71,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
tokenStart: 0,
|
tokenStart: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
onChange = (e) => {
|
onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||||
|
|
||||||
if (token !== null && this.state.lastToken !== token) {
|
if (token !== null && this.state.lastToken !== token) {
|
||||||
|
@ -82,7 +85,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
this.props.onChange(e);
|
this.props.onChange(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyDown = (e) => {
|
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
const { suggestions, disabled } = this.props;
|
const { suggestions, disabled } = this.props;
|
||||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||||
|
|
||||||
|
@ -91,7 +94,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.which === 229 || e.isComposing) {
|
if (e.which === 229 || (e as any).isComposing) {
|
||||||
// Ignore key events during text composition
|
// 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)
|
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
|
||||||
return;
|
return;
|
||||||
|
@ -100,7 +103,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
if (suggestions.size === 0 || suggestionsHidden) {
|
if (suggestions.size === 0 || suggestionsHidden) {
|
||||||
document.querySelector('.ui').parentElement.focus();
|
document.querySelector('.ui')?.parentElement?.focus();
|
||||||
} else {
|
} else {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.setState({ suggestionsHidden: true });
|
this.setState({ suggestionsHidden: true });
|
||||||
|
@ -156,14 +159,14 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuggestionClick = (e) => {
|
onSuggestionClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
||||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
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
|
// Skip updating when only the lastToken changes so the
|
||||||
// cursor doesn't jump around due to re-rendering unnecessarily
|
// cursor doesn't jump around due to re-rendering unnecessarily
|
||||||
const lastTokenUpdated = this.state.lastToken !== nextState.lastToken;
|
const lastTokenUpdated = this.state.lastToken !== nextState.lastToken;
|
||||||
|
@ -172,29 +175,29 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
if (lastTokenUpdated && !valueUpdated) {
|
if (lastTokenUpdated && !valueUpdated) {
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
return super.shouldComponentUpdate(nextProps, nextState);
|
return super.shouldComponentUpdate!(nextProps, nextState, undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
componentDidUpdate(prevProps: IAutosuggesteTextarea, prevState: any) {
|
||||||
const { suggestions } = this.props;
|
const { suggestions } = this.props;
|
||||||
if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) {
|
if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) {
|
||||||
this.setState({ suggestionsHidden: false });
|
this.setState({ suggestionsHidden: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTextarea = (c) => {
|
setTextarea: React.Ref<HTMLTextAreaElement> = (c) => {
|
||||||
this.textarea = c;
|
this.textarea = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
onPaste = (e) => {
|
onPaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||||
this.props.onPaste(e.clipboardData.files);
|
this.props.onPaste(e.clipboardData.files);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSuggestion = (suggestion, i) => {
|
renderSuggestion = (suggestion: string | Emoji, i: number) => {
|
||||||
const { selectedSuggestion } = this.state;
|
const { selectedSuggestion } = this.state;
|
||||||
let inner, key;
|
let inner, key;
|
||||||
|
|
||||||
|
@ -212,7 +215,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex='0'
|
tabIndex={0}
|
||||||
key={key}
|
key={key}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
className={classNames({
|
className={classNames({
|
||||||
|
@ -272,7 +275,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
onFocus={this.onFocus}
|
onFocus={this.onFocus}
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
onPaste={this.onPaste}
|
onPaste={this.onPaste}
|
||||||
style={style}
|
style={style as any}
|
||||||
aria-autocomplete='list'
|
aria-autocomplete='list'
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
@ -297,3 +300,5 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default AutosuggestTextarea;
|
|
@ -18,7 +18,7 @@ let id = 0;
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
|
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
|
||||||
middleClick?: React.EventHandler<React.MouseEvent>,
|
middleClick?: React.EventHandler<React.MouseEvent>,
|
||||||
text: string | JSX.Element,
|
text: string,
|
||||||
href?: string,
|
href?: string,
|
||||||
to?: string,
|
to?: string,
|
||||||
newTab?: boolean,
|
newTab?: boolean,
|
||||||
|
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -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} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon, { IIcon } from 'soapbox/components/icon';
|
||||||
import { Counter } from 'soapbox/components/ui';
|
import { Counter } from 'soapbox/components/ui';
|
||||||
|
|
||||||
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
@ -12,7 +12,7 @@ interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) => {
|
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) => {
|
||||||
return (
|
return (
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<Icon id={icon} {...rest} />
|
<Icon id={icon} {...rest as IIcon} />
|
||||||
|
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<i className='absolute -top-2 -right-2'>
|
<i className='absolute -top-2 -right-2'>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
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';
|
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. */
|
/** Desktop sidebar with links to different views in the app. */
|
||||||
const SidebarNavigation = () => {
|
const SidebarNavigation = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const instance = useAppSelector((state) => state.instance);
|
const instance = useAppSelector((state) => state.instance);
|
||||||
const settings = useAppSelector((state) => getSettings(state));
|
const settings = useAppSelector((state) => getSettings(state));
|
||||||
const account = useOwnAccount();
|
const account = useOwnAccount();
|
||||||
|
@ -30,7 +42,7 @@ const SidebarNavigation = () => {
|
||||||
if (account.locked || followRequestsCount > 0) {
|
if (account.locked || followRequestsCount > 0) {
|
||||||
menu.push({
|
menu.push({
|
||||||
to: '/follow_requests',
|
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'),
|
icon: require('@tabler/icons/user-plus.svg'),
|
||||||
count: followRequestsCount,
|
count: followRequestsCount,
|
||||||
});
|
});
|
||||||
|
@ -39,7 +51,7 @@ const SidebarNavigation = () => {
|
||||||
if (features.bookmarks) {
|
if (features.bookmarks) {
|
||||||
menu.push({
|
menu.push({
|
||||||
to: '/bookmarks',
|
to: '/bookmarks',
|
||||||
text: <FormattedMessage id='column.bookmarks' defaultMessage='Bookmarks' />,
|
text: intl.formatMessage(messages.bookmarks),
|
||||||
icon: require('@tabler/icons/bookmark.svg'),
|
icon: require('@tabler/icons/bookmark.svg'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -47,7 +59,7 @@ const SidebarNavigation = () => {
|
||||||
if (features.lists) {
|
if (features.lists) {
|
||||||
menu.push({
|
menu.push({
|
||||||
to: '/lists',
|
to: '/lists',
|
||||||
text: <FormattedMessage id='column.lists' defaultMessage='Lists' />,
|
text: intl.formatMessage(messages.lists),
|
||||||
icon: require('@tabler/icons/list.svg'),
|
icon: require('@tabler/icons/list.svg'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -56,7 +68,7 @@ const SidebarNavigation = () => {
|
||||||
menu.push({
|
menu.push({
|
||||||
to: '/developers',
|
to: '/developers',
|
||||||
icon: require('@tabler/icons/code.svg'),
|
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({
|
menu.push({
|
||||||
to: '/soapbox/admin',
|
to: '/soapbox/admin',
|
||||||
icon: require('@tabler/icons/dashboard.svg'),
|
icon: require('@tabler/icons/dashboard.svg'),
|
||||||
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
|
text: intl.formatMessage(messages.dashboard),
|
||||||
count: dashboardCount,
|
count: dashboardCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -78,7 +90,7 @@ const SidebarNavigation = () => {
|
||||||
menu.push({
|
menu.push({
|
||||||
to: '/timeline/local',
|
to: '/timeline/local',
|
||||||
icon: features.federating ? require('@tabler/icons/users.svg') : require('@tabler/icons/world.svg'),
|
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({
|
menu.push({
|
||||||
to: '/timeline/fediverse',
|
to: '/timeline/fediverse',
|
||||||
icon: require('icons/fediverse.svg'),
|
icon: require('icons/fediverse.svg'),
|
||||||
text: <FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />,
|
text: intl.formatMessage(messages.fediverse),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||||
/** ID of the timeline in Redux. */
|
/** ID of the timeline in Redux. */
|
||||||
timelineId?: string,
|
timelineId?: string,
|
||||||
/** Whether to display a gap or border between statuses in the list. */
|
/** Whether to display a gap or border between statuses in the list. */
|
||||||
divideType: 'space' | 'border',
|
divideType?: 'space' | 'border',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feed of statuses, built atop ScrollableList. */
|
/** Feed of statuses, built atop ScrollableList. */
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -18,6 +18,8 @@ export interface IColumn {
|
||||||
withHeader?: boolean,
|
withHeader?: boolean,
|
||||||
/** Extra class name for top <div> element. */
|
/** Extra class name for top <div> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
|
/** Ref forwarded to column. */
|
||||||
|
ref?: React.Ref<HTMLDivElement>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A backdrop for the main section of the UI. */
|
/** A backdrop for the main section of the UI. */
|
||||||
|
|
|
@ -84,7 +84,7 @@ const Aliases = () => {
|
||||||
<Text tag='span'>{alias}</Text>
|
<Text tag='span'>{alias}</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={alias} aria-label={intl.formatMessage(messages.delete)}>
|
<div className='flex items-center' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={alias} aria-label={intl.formatMessage(messages.delete)}>
|
||||||
<Icon className='pr-1.5 text-lg' id='times' size={40} />
|
<Icon className='pr-1.5 text-lg' id='times' />
|
||||||
<Text weight='bold' theme='muted'><FormattedMessage id='aliases.aliases_list_delete' defaultMessage='Unlink alias' /></Text>
|
<Text weight='bold' theme='muted'><FormattedMessage id='aliases.aliases_list_delete' defaultMessage='Unlink alias' /></Text>
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
|
@ -216,7 +216,7 @@ const Filters = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}>
|
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}>
|
||||||
<Icon className='filter__delete-icon' id='times' size={40} />
|
<Icon className='filter__delete-icon' id='times' />
|
||||||
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
|
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { updateNotifications } from '../../../../actions/notifications';
|
import { updateNotifications } from 'soapbox/actions/notifications';
|
||||||
import { render, screen, rootState, createTestStore } from '../../../../jest/test-helpers';
|
import { render, screen, rootState, createTestStore } from 'soapbox/jest/test-helpers';
|
||||||
import { makeGetNotification } from '../../../../selectors';
|
|
||||||
import Notification from '../notification';
|
|
||||||
|
|
||||||
const getNotification = makeGetNotification();
|
import Notification from '../notification';
|
||||||
|
|
||||||
/** Prepare the notification for use by the component */
|
/** Prepare the notification for use by the component */
|
||||||
const normalize = (notification: any) => {
|
const normalize = (notification: any) => {
|
||||||
|
@ -15,7 +13,7 @@ const normalize = (notification: any) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
notification: getNotification(state, state.notifications.items.get(notification.id)),
|
notification: state.notifications.items.get(notification.id),
|
||||||
state,
|
state,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
|
|
||||||
export default class ClearColumnButton extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><Icon src={require('@tabler/icons/eraser.svg')} /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import Icon from 'soapbox/components/icon';
|
||||||
|
|
||||||
|
interface IClearColumnButton {
|
||||||
|
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClearColumnButton: React.FC<IClearColumnButton> = ({ onClick }) => (
|
||||||
|
<button className='text-btn column-header__setting-btn' tabIndex={0} onClick={onClick}>
|
||||||
|
<Icon src={require('@tabler/icons/eraser.svg')} />
|
||||||
|
{' '}
|
||||||
|
<FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ClearColumnButton;
|
|
@ -1,106 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
import { Tabs } from 'soapbox/components/ui';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
all: { id: 'notifications.filter.all', defaultMessage: 'All' },
|
|
||||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
|
||||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' },
|
|
||||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' },
|
|
||||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
|
||||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
|
||||||
moves: { id: 'notifications.filter.moves', defaultMessage: 'Moves' },
|
|
||||||
emoji_reacts: { id: 'notifications.filter.emoji_reacts', defaultMessage: 'Emoji reacts' },
|
|
||||||
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @injectIntl
|
|
||||||
class NotificationFilterBar extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
selectFilter: PropTypes.func.isRequired,
|
|
||||||
selectedFilter: PropTypes.string.isRequired,
|
|
||||||
advancedMode: PropTypes.bool.isRequired,
|
|
||||||
supportsEmojiReacts: PropTypes.bool,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
onClick(notificationType) {
|
|
||||||
return () => this.props.selectFilter(notificationType);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { selectedFilter, advancedMode, supportsEmojiReacts, intl } = this.props;
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
text: intl.formatMessage(messages.all),
|
|
||||||
action: this.onClick('all'),
|
|
||||||
name: 'all',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!advancedMode) {
|
|
||||||
items.push({
|
|
||||||
text: intl.formatMessage(messages.mentions),
|
|
||||||
action: this.onClick('mention'),
|
|
||||||
name: 'mention',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
text: <Icon src={require('@tabler/icons/at.svg')} />,
|
|
||||||
title: intl.formatMessage(messages.mentions),
|
|
||||||
action: this.onClick('mention'),
|
|
||||||
name: 'mention',
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
text: <Icon src={require('@tabler/icons/heart.svg')} />,
|
|
||||||
title: intl.formatMessage(messages.favourites),
|
|
||||||
action: this.onClick('favourite'),
|
|
||||||
name: 'favourite',
|
|
||||||
});
|
|
||||||
if (supportsEmojiReacts) items.push({
|
|
||||||
text: <Icon src={require('@tabler/icons/mood-smile.svg')} />,
|
|
||||||
title: intl.formatMessage(messages.emoji_reacts),
|
|
||||||
action: this.onClick('pleroma:emoji_reaction'),
|
|
||||||
name: 'pleroma:emoji_reaction',
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
text: <Icon src={require('feather-icons/dist/icons/repeat.svg')} />,
|
|
||||||
title: intl.formatMessage(messages.boosts),
|
|
||||||
action: this.onClick('reblog'),
|
|
||||||
name: 'reblog',
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
text: <Icon src={require('@tabler/icons/chart-bar.svg')} />,
|
|
||||||
title: intl.formatMessage(messages.polls),
|
|
||||||
action: this.onClick('poll'),
|
|
||||||
name: 'poll',
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
text: <Icon src={require('@tabler/icons/bell-ringing.svg')} />,
|
|
||||||
title: intl.formatMessage(messages.statuses),
|
|
||||||
action: this.onClick('status'),
|
|
||||||
name: 'status',
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
text: <Icon src={require('@tabler/icons/user-plus.svg')} />,
|
|
||||||
title: intl.formatMessage(messages.follows),
|
|
||||||
action: this.onClick('follow'),
|
|
||||||
name: 'follow',
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
text: <Icon src={require('feather-icons/dist/icons/briefcase.svg')} />,
|
|
||||||
title: intl.formatMessage(messages.moves),
|
|
||||||
action: this.onClick('move'),
|
|
||||||
name: 'move',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { setFilter } from 'soapbox/actions/notifications';
|
||||||
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import { Tabs } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useFeatures, useSettings } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Item } from 'soapbox/components/ui/tabs/tabs';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
all: { id: 'notifications.filter.all', defaultMessage: 'All' },
|
||||||
|
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||||
|
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' },
|
||||||
|
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' },
|
||||||
|
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||||
|
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||||
|
moves: { id: 'notifications.filter.moves', defaultMessage: 'Moves' },
|
||||||
|
emoji_reacts: { id: 'notifications.filter.emoji_reacts', defaultMessage: 'Emoji reacts' },
|
||||||
|
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const NotificationFilterBar = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const settings = useSettings();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const selectedFilter = settings.getIn(['notifications', 'quickFilter', 'active']) as string;
|
||||||
|
const advancedMode = settings.getIn(['notifications', 'quickFilter', 'advanced']);
|
||||||
|
|
||||||
|
const onClick = (notificationType: string) => () => dispatch(setFilter(notificationType));
|
||||||
|
|
||||||
|
const items: Item[] = [
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.all),
|
||||||
|
action: onClick('all'),
|
||||||
|
name: 'all',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!advancedMode) {
|
||||||
|
items.push({
|
||||||
|
text: intl.formatMessage(messages.mentions),
|
||||||
|
action: onClick('mention'),
|
||||||
|
name: 'mention',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
text: <Icon src={require('@tabler/icons/at.svg')} />,
|
||||||
|
title: intl.formatMessage(messages.mentions),
|
||||||
|
action: onClick('mention'),
|
||||||
|
name: 'mention',
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
text: <Icon src={require('@tabler/icons/heart.svg')} />,
|
||||||
|
title: intl.formatMessage(messages.favourites),
|
||||||
|
action: onClick('favourite'),
|
||||||
|
name: 'favourite',
|
||||||
|
});
|
||||||
|
if (features.emojiReacts) items.push({
|
||||||
|
text: <Icon src={require('@tabler/icons/mood-smile.svg')} />,
|
||||||
|
title: intl.formatMessage(messages.emoji_reacts),
|
||||||
|
action: onClick('pleroma:emoji_reaction'),
|
||||||
|
name: 'pleroma:emoji_reaction',
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
text: <Icon src={require('feather-icons/dist/icons/repeat.svg')} />,
|
||||||
|
title: intl.formatMessage(messages.boosts),
|
||||||
|
action: onClick('reblog'),
|
||||||
|
name: 'reblog',
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
text: <Icon src={require('@tabler/icons/chart-bar.svg')} />,
|
||||||
|
title: intl.formatMessage(messages.polls),
|
||||||
|
action: onClick('poll'),
|
||||||
|
name: 'poll',
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
text: <Icon src={require('@tabler/icons/bell-ringing.svg')} />,
|
||||||
|
title: intl.formatMessage(messages.statuses),
|
||||||
|
action: onClick('status'),
|
||||||
|
name: 'status',
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
text: <Icon src={require('@tabler/icons/user-plus.svg')} />,
|
||||||
|
title: intl.formatMessage(messages.follows),
|
||||||
|
action: onClick('follow'),
|
||||||
|
name: 'follow',
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
text: <Icon src={require('feather-icons/dist/icons/briefcase.svg')} />,
|
||||||
|
title: intl.formatMessage(messages.moves),
|
||||||
|
action: onClick('move'),
|
||||||
|
name: 'move',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationFilterBar;
|
|
@ -1,19 +1,27 @@
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor } from 'react-intl';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { mentionCompose } from 'soapbox/actions/compose';
|
||||||
|
import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/interactions';
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
|
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import Permalink from 'soapbox/components/permalink';
|
import Permalink from 'soapbox/components/permalink';
|
||||||
import { HStack, Text, Emoji } from 'soapbox/components/ui';
|
import { HStack, Text, Emoji } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account_container';
|
import AccountContainer from 'soapbox/containers/account_container';
|
||||||
import StatusContainer from 'soapbox/containers/status_container';
|
import StatusContainer from 'soapbox/containers/status_container';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetNotification } from 'soapbox/selectors';
|
||||||
import { NotificationType, validType } from 'soapbox/utils/notification';
|
import { NotificationType, validType } from 'soapbox/utils/notification';
|
||||||
|
|
||||||
import type { ScrollPosition } from 'soapbox/components/status';
|
import type { ScrollPosition } from 'soapbox/components/status';
|
||||||
import type { Account, Status, Notification as NotificationEntity } from 'soapbox/types/entities';
|
import type { Account, Status, Notification as NotificationEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const getNotification = makeGetNotification();
|
||||||
|
|
||||||
const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: Date) => {
|
const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: Date) => {
|
||||||
const output = [message];
|
const output = [message];
|
||||||
|
|
||||||
|
@ -130,17 +138,17 @@ interface INotificaton {
|
||||||
notification: NotificationEntity,
|
notification: NotificationEntity,
|
||||||
onMoveUp?: (notificationId: string) => void,
|
onMoveUp?: (notificationId: string) => void,
|
||||||
onMoveDown?: (notificationId: string) => void,
|
onMoveDown?: (notificationId: string) => void,
|
||||||
onMention?: (account: Account) => void,
|
|
||||||
onFavourite?: (status: Status) => void,
|
|
||||||
onReblog?: (status: Status, e?: KeyboardEvent) => void,
|
onReblog?: (status: Status, e?: KeyboardEvent) => void,
|
||||||
onToggleHidden?: (status: Status) => void,
|
|
||||||
getScrollPosition?: () => ScrollPosition | undefined,
|
getScrollPosition?: () => ScrollPosition | undefined,
|
||||||
updateScrollBottom?: (bottom: number) => void,
|
updateScrollBottom?: (bottom: number) => void,
|
||||||
siteTitle?: string,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Notification: React.FC<INotificaton> = (props) => {
|
const Notification: React.FC<INotificaton> = (props) => {
|
||||||
const { hidden = false, notification, onMoveUp, onMoveDown } = props;
|
const { hidden = false, onMoveUp, onMoveDown } = props;
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const notification = useAppSelector((state) => getNotification(state, props.notification));
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
@ -175,31 +183,52 @@ const Notification: React.FC<INotificaton> = (props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMention = (e?: KeyboardEvent) => {
|
const handleMention = useCallback((e?: KeyboardEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
if (props.onMention && account && typeof account === 'object') {
|
if (account && typeof account === 'object') {
|
||||||
props.onMention(account);
|
dispatch(mentionCompose(account));
|
||||||
}
|
}
|
||||||
};
|
}, [account]);
|
||||||
|
|
||||||
const handleHotkeyFavourite = (e?: KeyboardEvent) => {
|
const handleHotkeyFavourite = useCallback((e?: KeyboardEvent) => {
|
||||||
if (props.onFavourite && status && typeof status === 'object') {
|
if (status && typeof status === 'object') {
|
||||||
props.onFavourite(status);
|
if (status.favourited) {
|
||||||
|
dispatch(unfavourite(status));
|
||||||
|
} else {
|
||||||
|
dispatch(favourite(status));
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
const handleHotkeyBoost = (e?: KeyboardEvent) => {
|
const handleHotkeyBoost = useCallback((e?: KeyboardEvent) => {
|
||||||
if (props.onReblog && status && typeof status === 'object') {
|
if (status && typeof status === 'object') {
|
||||||
props.onReblog(status, e);
|
dispatch((_, getState) => {
|
||||||
|
const boostModal = getSettings(getState()).get('boostModal');
|
||||||
|
if (status.reblogged) {
|
||||||
|
dispatch(unreblog(status));
|
||||||
|
} else {
|
||||||
|
if (e?.shiftKey || !boostModal) {
|
||||||
|
dispatch(reblog(status));
|
||||||
|
} else {
|
||||||
|
dispatch(openModal('BOOST', { status, onReblog: (status: Status) => {
|
||||||
|
dispatch(reblog(status));
|
||||||
|
} }));
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
const handleHotkeyToggleHidden = (e?: KeyboardEvent) => {
|
const handleHotkeyToggleHidden = useCallback((e?: KeyboardEvent) => {
|
||||||
if (props.onToggleHidden && status && typeof status === 'object') {
|
if (status && typeof status === 'object') {
|
||||||
props.onToggleHidden(status);
|
if (status.hidden) {
|
||||||
|
dispatch(revealStatus(status.id));
|
||||||
|
} else {
|
||||||
|
dispatch(hideStatus(status.id));
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
const handleMoveUp = () => {
|
const handleMoveUp = () => {
|
||||||
if (onMoveUp) {
|
if (onMoveUp) {
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { setFilter } from 'soapbox/actions/notifications';
|
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
|
||||||
|
|
||||||
import FilterBar from '../components/filter_bar';
|
|
||||||
|
|
||||||
const makeMapStateToProps = state => {
|
|
||||||
const settings = getSettings(state);
|
|
||||||
const instance = state.get('instance');
|
|
||||||
const features = getFeatures(instance);
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedFilter: settings.getIn(['notifications', 'quickFilter', 'active']),
|
|
||||||
advancedMode: settings.getIn(['notifications', 'quickFilter', 'advanced']),
|
|
||||||
supportsEmojiReacts: features.emojiReacts,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
selectFilter(newActiveFilter) {
|
|
||||||
dispatch(setFilter(newActiveFilter));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar);
|
|
|
@ -1,74 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { mentionCompose } from 'soapbox/actions/compose';
|
|
||||||
import {
|
|
||||||
reblog,
|
|
||||||
favourite,
|
|
||||||
unreblog,
|
|
||||||
unfavourite,
|
|
||||||
} from 'soapbox/actions/interactions';
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
|
||||||
import {
|
|
||||||
hideStatus,
|
|
||||||
revealStatus,
|
|
||||||
} from 'soapbox/actions/statuses';
|
|
||||||
import { makeGetNotification } from 'soapbox/selectors';
|
|
||||||
|
|
||||||
import Notification from '../components/notification';
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getNotification = makeGetNotification();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
|
||||||
return {
|
|
||||||
siteTitle: state.getIn(['instance', 'title']),
|
|
||||||
notification: getNotification(state, props.notification),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
onMention: (account) => {
|
|
||||||
dispatch(mentionCompose(account));
|
|
||||||
},
|
|
||||||
|
|
||||||
onModalReblog(status) {
|
|
||||||
dispatch(reblog(status));
|
|
||||||
},
|
|
||||||
|
|
||||||
onReblog(status, e) {
|
|
||||||
dispatch((_, getState) => {
|
|
||||||
const boostModal = getSettings(getState()).get('boostModal');
|
|
||||||
if (status.get('reblogged')) {
|
|
||||||
dispatch(unreblog(status));
|
|
||||||
} else {
|
|
||||||
if (e.shiftKey || !boostModal) {
|
|
||||||
this.onModalReblog(status);
|
|
||||||
} else {
|
|
||||||
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
onFavourite(status) {
|
|
||||||
if (status.get('favourited')) {
|
|
||||||
dispatch(unfavourite(status));
|
|
||||||
} else {
|
|
||||||
dispatch(favourite(status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onToggleHidden(status) {
|
|
||||||
if (status.get('hidden')) {
|
|
||||||
dispatch(revealStatus(status.get('id')));
|
|
||||||
} else {
|
|
||||||
dispatch(hideStatus(status.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
|
|
|
@ -1,215 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
|
|
||||||
import {
|
|
||||||
expandNotifications,
|
|
||||||
scrollTopNotifications,
|
|
||||||
dequeueNotifications,
|
|
||||||
} from 'soapbox/actions/notifications';
|
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
|
||||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
|
||||||
import ScrollTopButton from 'soapbox/components/scroll-top-button';
|
|
||||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
|
||||||
import { Column } from 'soapbox/components/ui';
|
|
||||||
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder_notification';
|
|
||||||
|
|
||||||
import FilterBarContainer from './containers/filter_bar_container';
|
|
||||||
import NotificationContainer from './containers/notification_container';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
|
||||||
queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const getNotifications = createSelector([
|
|
||||||
state => getSettings(state).getIn(['notifications', 'quickFilter', 'show']),
|
|
||||||
state => getSettings(state).getIn(['notifications', 'quickFilter', 'active']),
|
|
||||||
state => ImmutableList(getSettings(state).getIn(['notifications', 'shows']).filter(item => !item).keys()),
|
|
||||||
state => state.getIn(['notifications', 'items']).toList(),
|
|
||||||
], (showFilterBar, allowedType, excludedTypes, notifications) => {
|
|
||||||
if (!showFilterBar || allowedType === 'all') {
|
|
||||||
// used if user changed the notification settings after loading the notifications from the server
|
|
||||||
// otherwise a list of notifications will come pre-filtered from the backend
|
|
||||||
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
|
|
||||||
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
|
|
||||||
}
|
|
||||||
return notifications.filter(item => item !== null && allowedType === item.get('type'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
|
||||||
const settings = getSettings(state);
|
|
||||||
|
|
||||||
return {
|
|
||||||
showFilterBar: settings.getIn(['notifications', 'quickFilter', 'show']),
|
|
||||||
activeFilter: settings.getIn(['notifications', 'quickFilter', 'active']),
|
|
||||||
notifications: getNotifications(state),
|
|
||||||
isLoading: state.getIn(['notifications', 'isLoading'], true),
|
|
||||||
isUnread: state.getIn(['notifications', 'unread']) > 0,
|
|
||||||
hasMore: state.getIn(['notifications', 'hasMore']),
|
|
||||||
totalQueuedNotificationsCount: state.getIn(['notifications', 'totalQueuedNotificationsCount'], 0),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class Notifications extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
notifications: ImmutablePropTypes.list.isRequired,
|
|
||||||
showFilterBar: PropTypes.bool.isRequired,
|
|
||||||
activeFilter: PropTypes.string,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
isLoading: PropTypes.bool,
|
|
||||||
isUnread: PropTypes.bool,
|
|
||||||
hasMore: PropTypes.bool,
|
|
||||||
dequeueNotifications: PropTypes.func,
|
|
||||||
totalQueuedNotificationsCount: PropTypes.number,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.handleLoadOlder.cancel();
|
|
||||||
this.handleScrollToTop.cancel();
|
|
||||||
this.handleScroll.cancel();
|
|
||||||
this.props.dispatch(scrollTopNotifications(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.handleDequeueNotifications();
|
|
||||||
this.props.dispatch(scrollTopNotifications(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoadGap = (maxId) => {
|
|
||||||
this.props.dispatch(expandNotifications({ maxId }));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleLoadOlder = debounce(() => {
|
|
||||||
const last = this.props.notifications.last();
|
|
||||||
this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
|
|
||||||
}, 300, { leading: true });
|
|
||||||
|
|
||||||
handleScrollToTop = debounce(() => {
|
|
||||||
this.props.dispatch(scrollTopNotifications(true));
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
handleScroll = debounce(() => {
|
|
||||||
this.props.dispatch(scrollTopNotifications(false));
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.node = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
setColumnRef = c => {
|
|
||||||
this.column = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMoveUp = id => {
|
|
||||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
|
||||||
this._selectChild(elementIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMoveDown = id => {
|
|
||||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
|
||||||
this._selectChild(elementIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
_selectChild(index) {
|
|
||||||
this.node.scrollIntoView({
|
|
||||||
index,
|
|
||||||
behavior: 'smooth',
|
|
||||||
done: () => {
|
|
||||||
const container = this.column;
|
|
||||||
const element = container.querySelector(`[data-index="${index}"] .focusable`);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDequeueNotifications = () => {
|
|
||||||
this.props.dispatch(dequeueNotifications());
|
|
||||||
};
|
|
||||||
|
|
||||||
handleRefresh = () => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
return dispatch(expandNotifications());
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, notifications, isLoading, hasMore, showFilterBar, totalQueuedNotificationsCount, activeFilter } = this.props;
|
|
||||||
|
|
||||||
const emptyMessage = activeFilter === 'all'
|
|
||||||
? <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
|
|
||||||
: <FormattedMessage id='empty_column.notifications_filtered' defaultMessage="You don't have any notifications of this type yet." />;
|
|
||||||
|
|
||||||
let scrollableContent = null;
|
|
||||||
|
|
||||||
const filterBarContainer = showFilterBar
|
|
||||||
? (<FilterBarContainer />)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (isLoading && this.scrollableContent) {
|
|
||||||
scrollableContent = this.scrollableContent;
|
|
||||||
} else if (notifications.size > 0 || hasMore) {
|
|
||||||
scrollableContent = notifications.map((item, index) => (
|
|
||||||
<NotificationContainer
|
|
||||||
key={item.get('id')}
|
|
||||||
notification={item}
|
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
scrollableContent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scrollableContent = scrollableContent;
|
|
||||||
|
|
||||||
const scrollContainer = (
|
|
||||||
<ScrollableList
|
|
||||||
ref={this.setRef}
|
|
||||||
scrollKey='notifications'
|
|
||||||
isLoading={isLoading}
|
|
||||||
showLoading={isLoading && notifications.size === 0}
|
|
||||||
hasMore={hasMore}
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
placeholderComponent={PlaceholderNotification}
|
|
||||||
placeholderCount={20}
|
|
||||||
onLoadMore={this.handleLoadOlder}
|
|
||||||
onScrollToTop={this.handleScrollToTop}
|
|
||||||
onScroll={this.handleScroll}
|
|
||||||
className={classNames({
|
|
||||||
'divide-y divide-gray-200 dark:divide-gray-600 divide-solid': notifications.size > 0,
|
|
||||||
'space-y-2': notifications.size === 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{scrollableContent}
|
|
||||||
</ScrollableList>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column ref={this.setColumnRef} label={intl.formatMessage(messages.title)} withHeader={false}>
|
|
||||||
{filterBarContainer}
|
|
||||||
<ScrollTopButton
|
|
||||||
onClick={this.handleDequeueNotifications}
|
|
||||||
count={totalQueuedNotificationsCount}
|
|
||||||
message={messages.queue}
|
|
||||||
/>
|
|
||||||
<PullToRefresh onRefresh={this.handleRefresh}>
|
|
||||||
{scrollContainer}
|
|
||||||
</PullToRefresh>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,191 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expandNotifications,
|
||||||
|
scrollTopNotifications,
|
||||||
|
dequeueNotifications,
|
||||||
|
} from 'soapbox/actions/notifications';
|
||||||
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
|
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||||
|
import ScrollTopButton from 'soapbox/components/scroll-top-button';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
|
import { Column } from 'soapbox/components/ui';
|
||||||
|
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder_notification';
|
||||||
|
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import FilterBar from './components/filter_bar';
|
||||||
|
import Notification from './components/notification';
|
||||||
|
|
||||||
|
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
import type { RootState } from 'soapbox/store';
|
||||||
|
import type { Notification as NotificationEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||||
|
queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getNotifications = createSelector([
|
||||||
|
state => getSettings(state).getIn(['notifications', 'quickFilter', 'show']),
|
||||||
|
state => getSettings(state).getIn(['notifications', 'quickFilter', 'active']),
|
||||||
|
state => ImmutableList((getSettings(state).getIn(['notifications', 'shows']) as ImmutableMap<string, boolean>).filter(item => !item).keys()),
|
||||||
|
(state: RootState) => state.notifications.items.toList(),
|
||||||
|
], (showFilterBar, allowedType, excludedTypes, notifications: ImmutableList<NotificationEntity>) => {
|
||||||
|
if (!showFilterBar || allowedType === 'all') {
|
||||||
|
// used if user changed the notification settings after loading the notifications from the server
|
||||||
|
// otherwise a list of notifications will come pre-filtered from the backend
|
||||||
|
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
|
||||||
|
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
|
||||||
|
}
|
||||||
|
return notifications.filter(item => item !== null && allowedType === item.get('type'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const Notifications = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
|
const showFilterBar = settings.getIn(['notifications', 'quickFilter', 'show']);
|
||||||
|
const activeFilter = settings.getIn(['notifications', 'quickFilter', 'active']);
|
||||||
|
const notifications = useAppSelector(state => getNotifications(state));
|
||||||
|
const isLoading = useAppSelector(state => state.notifications.isLoading);
|
||||||
|
// const isUnread = useAppSelector(state => state.notifications.unread > 0);
|
||||||
|
const hasMore = useAppSelector(state => state.notifications.hasMore);
|
||||||
|
const totalQueuedNotificationsCount = useAppSelector(state => state.notifications.totalQueuedNotificationsCount || 0);
|
||||||
|
|
||||||
|
const node = useRef<VirtuosoHandle>(null);
|
||||||
|
const column = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollableContentRef = useRef<ImmutableList<JSX.Element> | null>(null);
|
||||||
|
|
||||||
|
// const handleLoadGap = (maxId) => {
|
||||||
|
// dispatch(expandNotifications({ maxId }));
|
||||||
|
// };
|
||||||
|
|
||||||
|
const handleLoadOlder = useCallback(debounce(() => {
|
||||||
|
const last = notifications.last();
|
||||||
|
dispatch(expandNotifications({ maxId: last && last.get('id') }));
|
||||||
|
}, 300, { leading: true }), []);
|
||||||
|
|
||||||
|
const handleScrollToTop = useCallback(debounce(() => {
|
||||||
|
dispatch(scrollTopNotifications(true));
|
||||||
|
}, 100), []);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(debounce(() => {
|
||||||
|
dispatch(scrollTopNotifications(false));
|
||||||
|
}, 100), []);
|
||||||
|
|
||||||
|
const handleMoveUp = (id: string) => {
|
||||||
|
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
||||||
|
_selectChild(elementIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveDown = (id: string) => {
|
||||||
|
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
||||||
|
_selectChild(elementIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _selectChild = (index: number) => {
|
||||||
|
node.current?.scrollIntoView({
|
||||||
|
index,
|
||||||
|
behavior: 'smooth',
|
||||||
|
done: () => {
|
||||||
|
const container = column.current;
|
||||||
|
const element = container?.querySelector(`[data-index="${index}"] .focusable`);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
(element as HTMLDivElement).focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDequeueNotifications = () => {
|
||||||
|
dispatch(dequeueNotifications());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
return dispatch(expandNotifications());
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleDequeueNotifications();
|
||||||
|
dispatch(scrollTopNotifications(true));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handleLoadOlder.cancel();
|
||||||
|
handleScrollToTop.cancel();
|
||||||
|
handleScroll.cancel();
|
||||||
|
dispatch(scrollTopNotifications(false));
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const emptyMessage = activeFilter === 'all'
|
||||||
|
? <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
|
||||||
|
: <FormattedMessage id='empty_column.notifications_filtered' defaultMessage="You don't have any notifications of this type yet." />;
|
||||||
|
|
||||||
|
let scrollableContent: ImmutableList<JSX.Element> | null = null;
|
||||||
|
|
||||||
|
const filterBarContainer = showFilterBar
|
||||||
|
? (<FilterBar />)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isLoading && scrollableContentRef.current) {
|
||||||
|
scrollableContent = scrollableContentRef.current;
|
||||||
|
} else if (notifications.size > 0 || hasMore) {
|
||||||
|
scrollableContent = notifications.map((item) => (
|
||||||
|
<Notification
|
||||||
|
key={item.id}
|
||||||
|
notification={item}
|
||||||
|
onMoveUp={handleMoveUp}
|
||||||
|
onMoveDown={handleMoveDown}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
scrollableContent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollableContentRef.current = scrollableContent;
|
||||||
|
|
||||||
|
const scrollContainer = (
|
||||||
|
<ScrollableList
|
||||||
|
ref={node}
|
||||||
|
scrollKey='notifications'
|
||||||
|
isLoading={isLoading}
|
||||||
|
showLoading={isLoading && notifications.size === 0}
|
||||||
|
hasMore={hasMore}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
placeholderComponent={PlaceholderNotification}
|
||||||
|
placeholderCount={20}
|
||||||
|
onLoadMore={handleLoadOlder}
|
||||||
|
onScrollToTop={handleScrollToTop}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className={classNames({
|
||||||
|
'divide-y divide-gray-200 dark:divide-gray-600 divide-solid': notifications.size > 0,
|
||||||
|
'space-y-2': notifications.size === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{scrollableContent as ImmutableList<JSX.Element>}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column ref={column} label={intl.formatMessage(messages.title)} withHeader={false}>
|
||||||
|
{filterBarContainer}
|
||||||
|
<ScrollTopButton
|
||||||
|
onClick={handleDequeueNotifications}
|
||||||
|
count={totalQueuedNotificationsCount}
|
||||||
|
message={messages.queue}
|
||||||
|
/>
|
||||||
|
<PullToRefresh onRefresh={handleRefresh}>
|
||||||
|
{scrollContainer}
|
||||||
|
</PullToRefresh>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Notifications;
|
|
@ -1,66 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchPinnedStatuses } from 'soapbox/actions/pin_statuses';
|
|
||||||
import MissingIndicator from 'soapbox/components/missing_indicator';
|
|
||||||
import StatusList from 'soapbox/components/status_list';
|
|
||||||
|
|
||||||
import Column from '../ui/components/column';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: { id: 'column.pins', defaultMessage: 'Pinned posts' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { params }) => {
|
|
||||||
const username = params.username || '';
|
|
||||||
const me = state.get('me');
|
|
||||||
const meUsername = state.getIn(['accounts', me, 'username'], '');
|
|
||||||
return {
|
|
||||||
isMyAccount: (username.toLowerCase() === meUsername.toLowerCase()),
|
|
||||||
statusIds: state.status_lists.get('pins').items,
|
|
||||||
hasMore: !!state.status_lists.get('pins').next,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class PinnedStatuses extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
statusIds: ImmutablePropTypes.orderedSet.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
hasMore: PropTypes.bool.isRequired,
|
|
||||||
isMyAccount: PropTypes.bool.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.dispatch(fetchPinnedStatuses());
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, statusIds, hasMore, isMyAccount } = this.props;
|
|
||||||
|
|
||||||
if (!isMyAccount) {
|
|
||||||
return (
|
|
||||||
<MissingIndicator />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
|
||||||
<StatusList
|
|
||||||
statusIds={statusIds}
|
|
||||||
scrollKey='pinned_statuses'
|
|
||||||
hasMore={hasMore}
|
|
||||||
emptyMessage={<FormattedMessage id='pinned_statuses.none' defaultMessage='No pins to show.' />}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { fetchPinnedStatuses } from 'soapbox/actions/pin_statuses';
|
||||||
|
import MissingIndicator from 'soapbox/components/missing_indicator';
|
||||||
|
import StatusList from 'soapbox/components/status_list';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import Column from '../ui/components/column';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.pins', defaultMessage: 'Pinned posts' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const PinnedStatuses = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { username } = useParams<{ username: string }>();
|
||||||
|
|
||||||
|
const meUsername = useAppSelector((state) => state.accounts.get(state.me)?.username || '');
|
||||||
|
const statusIds = useAppSelector((state) => state.status_lists.get('pins')!.items);
|
||||||
|
const isLoading = useAppSelector((state) => !!state.status_lists.get('pins')!.isLoading);
|
||||||
|
const hasMore = useAppSelector((state) => !!state.status_lists.get('pins')!.next);
|
||||||
|
|
||||||
|
const isMyAccount = username.toLowerCase() === meUsername.toLowerCase();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchPinnedStatuses());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isMyAccount) {
|
||||||
|
return (
|
||||||
|
<MissingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
|
<StatusList
|
||||||
|
statusIds={statusIds}
|
||||||
|
scrollKey='pinned_statuses'
|
||||||
|
hasMore={hasMore}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage={<FormattedMessage id='pinned_statuses.none' defaultMessage='No pins to show.' />}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PinnedStatuses;
|
|
@ -1,63 +0,0 @@
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
|
||||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
|
||||||
import { Text } from 'soapbox/components/ui';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
|
||||||
const soapboxConfig = getSoapboxConfig(state);
|
|
||||||
|
|
||||||
return {
|
|
||||||
copyright: soapboxConfig.get('copyright'),
|
|
||||||
navlinks: soapboxConfig.getIn(['navlinks', 'homeFooter'], ImmutableList()),
|
|
||||||
locale: getSettings(state).get('locale'),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
class Footer extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
copyright: PropTypes.string,
|
|
||||||
locale: PropTypes.string,
|
|
||||||
navlinks: ImmutablePropTypes.list,
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { copyright, locale, navlinks } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className='relative max-w-7xl mt-auto mx-auto py-12 px-4 sm:px-6 xl:flex xl:items-center xl:justify-between lg:px-8'>
|
|
||||||
<div className='flex flex-wrap justify-center'>
|
|
||||||
{navlinks.map((link, idx) => {
|
|
||||||
const url = link.get('url');
|
|
||||||
const isExternal = url.startsWith('http');
|
|
||||||
const Comp = isExternal ? 'a' : Link;
|
|
||||||
const compProps = isExternal ? { href: url, target: '_blank' } : { to: url };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={idx} className='px-5 py-2'>
|
|
||||||
<Comp {...compProps} className='hover:underline'>
|
|
||||||
<Text tag='span' theme='primary' size='sm'>
|
|
||||||
{link.getIn(['titleLocales', locale]) || link.get('title')}
|
|
||||||
</Text>
|
|
||||||
</Comp>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6 xl:mt-0'>
|
|
||||||
<Text theme='muted' align='center' size='sm'>{copyright}</Text>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
|
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||||
|
import { Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { FooterItem } from 'soapbox/types/soapbox';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const { copyright, navlinks, locale } = useAppSelector((state) => {
|
||||||
|
const soapboxConfig = getSoapboxConfig(state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
copyright: soapboxConfig.copyright,
|
||||||
|
navlinks: (soapboxConfig.navlinks.get('homeFooter') || ImmutableList()) as ImmutableList<FooterItem>,
|
||||||
|
locale: getSettings(state).get('locale') as string,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className='relative max-w-7xl mt-auto mx-auto py-12 px-4 sm:px-6 xl:flex xl:items-center xl:justify-between lg:px-8'>
|
||||||
|
<div className='flex flex-wrap justify-center'>
|
||||||
|
{navlinks.map((link, idx) => {
|
||||||
|
const url = link.get('url');
|
||||||
|
const isExternal = url.startsWith('http');
|
||||||
|
const Comp = (isExternal ? 'a' : Link) as 'a';
|
||||||
|
const compProps = isExternal ? { href: url, target: '_blank' } : { to: url };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className='px-5 py-2'>
|
||||||
|
<Comp {...compProps} className='hover:underline'>
|
||||||
|
<Text tag='span' theme='primary' size='sm'>
|
||||||
|
{(link.getIn(['titleLocales', locale]) || link.get('title')) as string}
|
||||||
|
</Text>
|
||||||
|
</Comp>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 xl:mt-0'>
|
||||||
|
<Text theme='muted' align='center' size='sm'>{copyright}</Text>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
|
@ -40,7 +40,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
|
||||||
className={classNames({ active, destructive })}
|
className={classNames({ active, destructive })}
|
||||||
data-method={isLogout ? 'delete' : null}
|
data-method={isLogout ? 'delete' : null}
|
||||||
>
|
>
|
||||||
{icon && <Icon title={text} src={icon} role='presentation' tabIndex='-1' inverted />}
|
{icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />}
|
||||||
<div>
|
<div>
|
||||||
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
|
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
|
||||||
<div>{meta}</div>
|
<div>{meta}</div>
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import IconButton from '../../../components/icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' },
|
|
||||||
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
|
|
||||||
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
|
|
||||||
});
|
|
||||||
|
|
||||||
class BundleModalError extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onRetry: PropTypes.func.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
handleRetry = () => {
|
|
||||||
this.props.onRetry();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { onClose, intl: { formatMessage } } = this.props;
|
|
||||||
|
|
||||||
// Keep the markup in sync with <ModalLoading />
|
|
||||||
// (make sure they have the same dimensions)
|
|
||||||
return (
|
|
||||||
<div className='modal-root__modal error-modal'>
|
|
||||||
<div className='error-modal__body'>
|
|
||||||
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
|
|
||||||
{formatMessage(messages.error)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='error-modal__footer'>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className='error-modal__nav onboarding-modal__skip'
|
|
||||||
>
|
|
||||||
{formatMessage(messages.close)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(BundleModalError);
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import IconButton from 'soapbox/components/icon_button';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' },
|
||||||
|
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
|
||||||
|
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IBundleModalError {
|
||||||
|
onRetry: () => void,
|
||||||
|
onClose: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BundleModalError: React.FC<IBundleModalError> = ({ onRetry, onClose }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
onRetry();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='modal-root__modal error-modal'>
|
||||||
|
<div className='error-modal__body'>
|
||||||
|
<IconButton title={intl.formatMessage(messages.retry)} icon='refresh' onClick={handleRetry} size={64} />
|
||||||
|
{intl.formatMessage(messages.error)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='error-modal__footer'>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className='error-modal__nav onboarding-modal__skip'
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.close)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BundleModalError;
|
Loading…
Reference in New Issue