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/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/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)); } - }; - - const handleHotkeyFavourite = (e?: KeyboardEvent) => { - if (props.onFavourite && status && typeof status === 'object') { - props.onFavourite(status); + }, [account]); + + const handleHotkeyFavourite = useCallback((e?: KeyboardEvent) => { + if (status && typeof status === 'object') { + if (status.favourited) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } } - }; - - const handleHotkeyBoost = (e?: KeyboardEvent) => { - if (props.onReblog && status && typeof status === 'object') { - props.onReblog(status, e); + }, [status]); + + 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)); + } })); + } + } + }); } - }; - - const handleHotkeyToggleHidden = (e?: KeyboardEvent) => { - if (props.onToggleHidden && status && typeof status === 'object') { - props.onToggleHidden(status); + }, [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 834bb39cb..000000000 --- a/app/soapbox/features/notifications/index.js +++ /dev/null @@ -1,213 +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 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..7b0848365 --- /dev/null +++ b/app/soapbox/features/notifications/index.tsx @@ -0,0 +1,189 @@ +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 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;