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

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

View File

@@ -84,7 +84,7 @@ const Aliases = () => {
<Text tag='span'>{alias}</Text>
</div>
<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>
</div>
</HStack>

View File

@@ -216,7 +216,7 @@ const Filters = () => {
</div>
</div>
<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>
</div>
</div>

View File

@@ -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,
};
};

View File

@@ -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>
);
}
}

View File

@@ -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;

View File

@@ -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} />;
}
}

View File

@@ -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;

View File

@@ -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<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 intl = useIntl();
@@ -175,31 +183,52 @@ const Notification: React.FC<INotificaton> = (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) {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>
);
}
}

View File

@@ -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;

View File

@@ -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>
);
}
}

View File

@@ -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;

View File

@@ -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>
);
}
}

View File

@@ -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;

View File

@@ -40,7 +40,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
className={classNames({ active, destructive })}
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 className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
<div>{meta}</div>

View File

@@ -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);

View File

@@ -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;