diff --git a/app/soapbox/actions/notifications.ts b/app/soapbox/actions/notifications.ts index adeb46bf9..64311a15a 100644 --- a/app/soapbox/actions/notifications.ts +++ b/app/soapbox/actions/notifications.ts @@ -174,9 +174,9 @@ const excludeTypesFromFilter = (filter: string) => { return allTypes.filterNot(item => item === filter).toJS(); }; -const noOp = () => {}; +const noOp = () => new Promise(f => f(undefined)); -const expandNotifications = ({ maxId }: Record = {}, done = noOp) => +const expandNotifications = ({ maxId }: Record = {}, done: () => any = noOp) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return dispatch(noOp); diff --git a/app/soapbox/components/autosuggest_textarea.js b/app/soapbox/components/autosuggest_textarea.tsx similarity index 79% rename from app/soapbox/components/autosuggest_textarea.js rename to app/soapbox/components/autosuggest_textarea.tsx index 9a7ff45dc..69d29261f 100644 --- a/app/soapbox/components/autosuggest_textarea.js +++ b/app/soapbox/components/autosuggest_textarea.tsx @@ -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, + disabled: boolean, + placeholder: string, + onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void, + onSuggestionsClearRequested: () => void, + onSuggestionsFetchRequested: (token: string | number) => void, + onChange: React.ChangeEventHandler, + onKeyUp: React.KeyboardEventHandler, + onKeyDown: React.KeyboardEventHandler, + 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 { + + 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 = (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 = (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 = (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 = (c) => { this.textarea = c; } - onPaste = (e) => { + onPaste: React.ClipboardEventHandler = (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 (
@@ -297,3 +300,5 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } } + +export default AutosuggestTextarea; diff --git a/app/soapbox/components/dropdown_menu.tsx b/app/soapbox/components/dropdown_menu.tsx index ed8d87c16..3118b0d2d 100644 --- a/app/soapbox/components/dropdown_menu.tsx +++ b/app/soapbox/components/dropdown_menu.tsx @@ -18,7 +18,7 @@ let id = 0; export interface MenuItem { action?: React.EventHandler, middleClick?: React.EventHandler, - text: string | JSX.Element, + text: string, href?: string, to?: string, newTab?: boolean, diff --git a/app/soapbox/components/fork_awesome_icon.js b/app/soapbox/components/fork_awesome_icon.js deleted file mode 100644 index 1d85f1288..000000000 --- a/app/soapbox/components/fork_awesome_icon.js +++ /dev/null @@ -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 ( - - ); - } - -} diff --git a/app/soapbox/components/fork_awesome_icon.tsx b/app/soapbox/components/fork_awesome_icon.tsx new file mode 100644 index 000000000..45b146e08 --- /dev/null +++ b/app/soapbox/components/fork_awesome_icon.tsx @@ -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 { + id: string, + className?: string, + fixedWidth?: boolean, +} + +const ForkAwesomeIcon: React.FC = ({ 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 ( + + ); +}; + +export default ForkAwesomeIcon; diff --git a/app/soapbox/components/icon.js b/app/soapbox/components/icon.js deleted file mode 100644 index 3a7059061..000000000 --- a/app/soapbox/components/icon.js +++ /dev/null @@ -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 ; - } else { - return ; - } - } - -} diff --git a/app/soapbox/components/icon.tsx b/app/soapbox/components/icon.tsx new file mode 100644 index 000000000..cba7b5805 --- /dev/null +++ b/app/soapbox/components/icon.tsx @@ -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 = (props) => { + if ((props as ISvgIcon).src) { + const { src, ...rest } = (props as ISvgIcon); + + return ; + } else { + const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon); + + return ; + } +}; + +export default Icon; diff --git a/app/soapbox/components/icon_with_counter.tsx b/app/soapbox/components/icon_with_counter.tsx index d0fd093a6..2d95cb9f9 100644 --- a/app/soapbox/components/icon_with_counter.tsx +++ b/app/soapbox/components/icon_with_counter.tsx @@ -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 { @@ -12,7 +12,7 @@ interface IIconWithCounter extends React.HTMLAttributes { const IconWithCounter: React.FC = ({ icon, count, ...rest }) => { return (
- + {count > 0 && ( diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index b2bbc941f..583006070 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -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: , + 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: , + 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: , + 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: , + text: intl.formatMessage(messages.developers), }); } @@ -64,7 +76,7 @@ const SidebarNavigation = () => { menu.push({ to: '/soapbox/admin', icon: require('@tabler/icons/dashboard.svg'), - text: , + 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 : , + 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: , + text: intl.formatMessage(messages.fediverse), }); } diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 35edc9283..408506997 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -36,7 +36,7 @@ interface IStatusList extends Omit { /** 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. */ diff --git a/app/soapbox/components/sub_navigation.js b/app/soapbox/components/sub_navigation.js deleted file mode 100644 index f75ca802f..000000000 --- a/app/soapbox/components/sub_navigation.js +++ /dev/null @@ -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 ( - - - - ); - } - -} diff --git a/app/soapbox/components/sub_navigation.tsx b/app/soapbox/components/sub_navigation.tsx new file mode 100644 index 000000000..b8e2b310d --- /dev/null +++ b/app/soapbox/components/sub_navigation.tsx @@ -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 = ({ 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 ( + + + + ); +}; + +export default SubNavigation; diff --git a/app/soapbox/components/svg_icon.js b/app/soapbox/components/svg_icon.js deleted file mode 100644 index 04f0cd526..000000000 --- a/app/soapbox/components/svg_icon.js +++ /dev/null @@ -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 ( -
- } /> -
- ); - } - -} diff --git a/app/soapbox/components/svg_icon.tsx b/app/soapbox/components/svg_icon.tsx new file mode 100644 index 000000000..a81979d0d --- /dev/null +++ b/app/soapbox/components/svg_icon.tsx @@ -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 { + src: string, + id?: string, + alt?: string, + className?: string, +} + +const SvgIcon: React.FC = ({ src, alt, className, ...rest }) => { + return ( +
+ } /> +
+ ); +}; + +export default SvgIcon; diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 5ac4f350e..81b7fca1e 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -18,6 +18,8 @@ export interface IColumn { withHeader?: boolean, /** Extra class name for top
element. */ className?: string, + /** Ref forwarded to column. */ + ref?: React.Ref } /** A backdrop for the main section of the UI. */ diff --git a/app/soapbox/features/aliases/index.tsx b/app/soapbox/features/aliases/index.tsx index 0e765339e..9dfb52c44 100644 --- a/app/soapbox/features/aliases/index.tsx +++ b/app/soapbox/features/aliases/index.tsx @@ -84,7 +84,7 @@ const Aliases = () => { {alias}
- +
diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx index fcdf262eb..e2d6f2050 100644 --- a/app/soapbox/features/filters/index.tsx +++ b/app/soapbox/features/filters/index.tsx @@ -216,7 +216,7 @@ const Filters = () => {
- +
diff --git a/app/soapbox/features/notifications/components/__tests__/notification.test.tsx b/app/soapbox/features/notifications/components/__tests__/notification.test.tsx index eacca0f99..9c86474b7 100644 --- a/app/soapbox/features/notifications/components/__tests__/notification.test.tsx +++ b/app/soapbox/features/notifications/components/__tests__/notification.test.tsx @@ -1,11 +1,9 @@ import * as React from 'react'; -import { updateNotifications } from '../../../../actions/notifications'; -import { render, screen, rootState, createTestStore } from '../../../../jest/test-helpers'; -import { makeGetNotification } from '../../../../selectors'; -import Notification from '../notification'; +import { updateNotifications } from 'soapbox/actions/notifications'; +import { render, screen, rootState, createTestStore } from 'soapbox/jest/test-helpers'; -const getNotification = makeGetNotification(); +import Notification from '../notification'; /** Prepare the notification for use by the component */ const normalize = (notification: any) => { @@ -15,7 +13,7 @@ const normalize = (notification: any) => { return { // @ts-ignore - notification: getNotification(state, state.notifications.items.get(notification.id)), + notification: state.notifications.items.get(notification.id), state, }; }; diff --git a/app/soapbox/features/notifications/components/clear_column_button.js b/app/soapbox/features/notifications/components/clear_column_button.js deleted file mode 100644 index 709deab78..000000000 --- a/app/soapbox/features/notifications/components/clear_column_button.js +++ /dev/null @@ -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 ( - - ); - } - -} diff --git a/app/soapbox/features/notifications/components/clear_column_button.tsx b/app/soapbox/features/notifications/components/clear_column_button.tsx new file mode 100644 index 000000000..3d70545aa --- /dev/null +++ b/app/soapbox/features/notifications/components/clear_column_button.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Icon from 'soapbox/components/icon'; + +interface IClearColumnButton { + onClick: React.MouseEventHandler; +} + +const ClearColumnButton: React.FC = ({ onClick }) => ( + +); + +export default ClearColumnButton; diff --git a/app/soapbox/features/notifications/components/filter_bar.js b/app/soapbox/features/notifications/components/filter_bar.js deleted file mode 100644 index a656ce290..000000000 --- a/app/soapbox/features/notifications/components/filter_bar.js +++ /dev/null @@ -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: , - title: intl.formatMessage(messages.mentions), - action: this.onClick('mention'), - name: 'mention', - }); - items.push({ - text: , - title: intl.formatMessage(messages.favourites), - action: this.onClick('favourite'), - name: 'favourite', - }); - if (supportsEmojiReacts) items.push({ - text: , - title: intl.formatMessage(messages.emoji_reacts), - action: this.onClick('pleroma:emoji_reaction'), - name: 'pleroma:emoji_reaction', - }); - items.push({ - text: , - title: intl.formatMessage(messages.boosts), - action: this.onClick('reblog'), - name: 'reblog', - }); - items.push({ - text: , - title: intl.formatMessage(messages.polls), - action: this.onClick('poll'), - name: 'poll', - }); - items.push({ - text: , - title: intl.formatMessage(messages.statuses), - action: this.onClick('status'), - name: 'status', - }); - items.push({ - text: , - title: intl.formatMessage(messages.follows), - action: this.onClick('follow'), - name: 'follow', - }); - items.push({ - text: , - title: intl.formatMessage(messages.moves), - action: this.onClick('move'), - name: 'move', - }); - } - - return ; - } - -} diff --git a/app/soapbox/features/notifications/components/filter_bar.tsx b/app/soapbox/features/notifications/components/filter_bar.tsx new file mode 100644 index 000000000..7de482cdc --- /dev/null +++ b/app/soapbox/features/notifications/components/filter_bar.tsx @@ -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: , + title: intl.formatMessage(messages.mentions), + action: onClick('mention'), + name: 'mention', + }); + items.push({ + text: , + title: intl.formatMessage(messages.favourites), + action: onClick('favourite'), + name: 'favourite', + }); + if (features.emojiReacts) items.push({ + text: , + title: intl.formatMessage(messages.emoji_reacts), + action: onClick('pleroma:emoji_reaction'), + name: 'pleroma:emoji_reaction', + }); + items.push({ + text: , + title: intl.formatMessage(messages.boosts), + action: onClick('reblog'), + name: 'reblog', + }); + items.push({ + text: , + title: intl.formatMessage(messages.polls), + action: onClick('poll'), + name: 'poll', + }); + items.push({ + text: , + title: intl.formatMessage(messages.statuses), + action: onClick('status'), + name: 'status', + }); + items.push({ + text: , + title: intl.formatMessage(messages.follows), + action: onClick('follow'), + name: 'follow', + }); + items.push({ + text: , + title: intl.formatMessage(messages.moves), + action: onClick('move'), + name: 'move', + }); + } + + return ; +}; + +export default NotificationFilterBar; diff --git a/app/soapbox/features/notifications/components/notification.tsx b/app/soapbox/features/notifications/components/notification.tsx index 9f2c02778..e87b514ec 100644 --- a/app/soapbox/features/notifications/components/notification.tsx +++ b/app/soapbox/features/notifications/components/notification.tsx @@ -1,19 +1,27 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { HotKeys } from 'react-hotkeys'; import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor } from 'react-intl'; 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 Permalink from 'soapbox/components/permalink'; import { HStack, Text, Emoji } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_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 type { ScrollPosition } from 'soapbox/components/status'; import type { Account, Status, Notification as NotificationEntity } from 'soapbox/types/entities'; +const getNotification = makeGetNotification(); + const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: Date) => { const output = [message]; @@ -130,17 +138,17 @@ interface INotificaton { notification: NotificationEntity, onMoveUp?: (notificationId: string) => void, onMoveDown?: (notificationId: string) => void, - onMention?: (account: Account) => void, - onFavourite?: (status: Status) => void, onReblog?: (status: Status, e?: KeyboardEvent) => void, - onToggleHidden?: (status: Status) => void, getScrollPosition?: () => ScrollPosition | undefined, updateScrollBottom?: (bottom: number) => void, - siteTitle?: string, } const Notification: React.FC = (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 intl = useIntl(); @@ -175,31 +183,52 @@ const Notification: React.FC = (props) => { } }; - const handleMention = (e?: KeyboardEvent) => { + const handleMention = useCallback((e?: KeyboardEvent) => { e?.preventDefault(); - if (props.onMention && account && typeof account === 'object') { - props.onMention(account); + if (account && typeof account === 'object') { + dispatch(mentionCompose(account)); } - }; + }, [account]); - const handleHotkeyFavourite = (e?: KeyboardEvent) => { - if (props.onFavourite && status && typeof status === 'object') { - props.onFavourite(status); + const handleHotkeyFavourite = useCallback((e?: KeyboardEvent) => { + if (status && typeof status === 'object') { + if (status.favourited) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } } - }; + }, [status]); - const handleHotkeyBoost = (e?: KeyboardEvent) => { - if (props.onReblog && status && typeof status === 'object') { - props.onReblog(status, e); + const handleHotkeyBoost = useCallback((e?: KeyboardEvent) => { + if (status && typeof status === 'object') { + 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) => { - if (props.onToggleHidden && status && typeof status === 'object') { - props.onToggleHidden(status); + const handleHotkeyToggleHidden = useCallback((e?: KeyboardEvent) => { + if (status && typeof status === 'object') { + if (status.hidden) { + dispatch(revealStatus(status.id)); + } else { + dispatch(hideStatus(status.id)); + } } - }; + }, [status]); const handleMoveUp = () => { if (onMoveUp) { diff --git a/app/soapbox/features/notifications/containers/filter_bar_container.js b/app/soapbox/features/notifications/containers/filter_bar_container.js deleted file mode 100644 index cfb4345cc..000000000 --- a/app/soapbox/features/notifications/containers/filter_bar_container.js +++ /dev/null @@ -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); diff --git a/app/soapbox/features/notifications/containers/notification_container.js b/app/soapbox/features/notifications/containers/notification_container.js deleted file mode 100644 index e9530fb83..000000000 --- a/app/soapbox/features/notifications/containers/notification_container.js +++ /dev/null @@ -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); diff --git a/app/soapbox/features/notifications/index.js b/app/soapbox/features/notifications/index.js deleted file mode 100644 index 45797a6df..000000000 --- a/app/soapbox/features/notifications/index.js +++ /dev/null @@ -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' - ? - : ; - - let scrollableContent = null; - - const filterBarContainer = showFilterBar - ? () - : null; - - if (isLoading && this.scrollableContent) { - scrollableContent = this.scrollableContent; - } else if (notifications.size > 0 || hasMore) { - scrollableContent = notifications.map((item, index) => ( - - )); - } else { - scrollableContent = null; - } - - this.scrollableContent = scrollableContent; - - const scrollContainer = ( - 0, - 'space-y-2': notifications.size === 0, - })} - > - {scrollableContent} - - ); - - return ( - - {filterBarContainer} - - - {scrollContainer} - - - ); - } - -} diff --git a/app/soapbox/features/notifications/index.tsx b/app/soapbox/features/notifications/index.tsx new file mode 100644 index 000000000..62e67fdb5 --- /dev/null +++ b/app/soapbox/features/notifications/index.tsx @@ -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).filter(item => !item).keys()), + (state: RootState) => state.notifications.items.toList(), +], (showFilterBar, allowedType, excludedTypes, notifications: ImmutableList) => { + 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(null); + const column = useRef(null); + const scrollableContentRef = useRef | 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' + ? + : ; + + let scrollableContent: ImmutableList | null = null; + + const filterBarContainer = showFilterBar + ? () + : null; + + if (isLoading && scrollableContentRef.current) { + scrollableContent = scrollableContentRef.current; + } else if (notifications.size > 0 || hasMore) { + scrollableContent = notifications.map((item) => ( + + )); + } else { + scrollableContent = null; + } + + scrollableContentRef.current = scrollableContent; + + const scrollContainer = ( + 0, + 'space-y-2': notifications.size === 0, + })} + > + {scrollableContent as ImmutableList} + + ); + + return ( + + {filterBarContainer} + + + {scrollContainer} + + + ); +}; + +export default Notifications; diff --git a/app/soapbox/features/pinned_statuses/index.js b/app/soapbox/features/pinned_statuses/index.js deleted file mode 100644 index 9901131ba..000000000 --- a/app/soapbox/features/pinned_statuses/index.js +++ /dev/null @@ -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 ( - - ); - } - - return ( - - } - /> - - ); - } - -} diff --git a/app/soapbox/features/pinned_statuses/index.tsx b/app/soapbox/features/pinned_statuses/index.tsx new file mode 100644 index 000000000..74d4d0f98 --- /dev/null +++ b/app/soapbox/features/pinned_statuses/index.tsx @@ -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 ( + + ); + } + + return ( + + } + /> + + ); +}; + +export default PinnedStatuses; diff --git a/app/soapbox/features/public_layout/components/footer.js b/app/soapbox/features/public_layout/components/footer.js deleted file mode 100644 index 11a56806b..000000000 --- a/app/soapbox/features/public_layout/components/footer.js +++ /dev/null @@ -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 ( -
-
- {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 ( -
- - - {link.getIn(['titleLocales', locale]) || link.get('title')} - - -
- ); - })} -
- -
- {copyright} -
-
- ); - } - -} diff --git a/app/soapbox/features/public_layout/components/footer.tsx b/app/soapbox/features/public_layout/components/footer.tsx new file mode 100644 index 000000000..69f933339 --- /dev/null +++ b/app/soapbox/features/public_layout/components/footer.tsx @@ -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, + locale: getSettings(state).get('locale') as string, + }; + }); + + return ( +
+
+ {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 ( +
+ + + {(link.getIn(['titleLocales', locale]) || link.get('title')) as string} + + +
+ ); + })} +
+ +
+ {copyright} +
+
+ ); +}; + +export default Footer; diff --git a/app/soapbox/features/ui/components/actions_modal.tsx b/app/soapbox/features/ui/components/actions_modal.tsx index e760df031..554e3b214 100644 --- a/app/soapbox/features/ui/components/actions_modal.tsx +++ b/app/soapbox/features/ui/components/actions_modal.tsx @@ -40,7 +40,7 @@ const ActionsModal: React.FC = ({ status, actions, onClick, onClo className={classNames({ active, destructive })} data-method={isLogout ? 'delete' : null} > - {icon && } + {icon && }
{text}
{meta}
diff --git a/app/soapbox/features/ui/components/bundle_modal_error.js b/app/soapbox/features/ui/components/bundle_modal_error.js deleted file mode 100644 index 70d8265e3..000000000 --- a/app/soapbox/features/ui/components/bundle_modal_error.js +++ /dev/null @@ -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 - // (make sure they have the same dimensions) - return ( -
-
- - {formatMessage(messages.error)} -
- -
-
- -
-
-
- ); - } - -} - -export default injectIntl(BundleModalError); diff --git a/app/soapbox/features/ui/components/bundle_modal_error.tsx b/app/soapbox/features/ui/components/bundle_modal_error.tsx new file mode 100644 index 000000000..2945c442b --- /dev/null +++ b/app/soapbox/features/ui/components/bundle_modal_error.tsx @@ -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 = ({ onRetry, onClose }) => { + const intl = useIntl(); + + const handleRetry = () => { + onRetry(); + }; + + return ( +
+
+ + {intl.formatMessage(messages.error)} +
+ +
+
+ +
+
+
+ ); +}; + +export default BundleModalError;