+
{hosts.map((host) => )}
diff --git a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx
index eb84e7ad0..d7f439fa2 100644
--- a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx
+++ b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx
@@ -56,11 +56,31 @@ describe('
', () => {
});
it('should render the Carousel', () => {
+ store.carousels = {
+ avatars: [
+ { account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' },
+ ],
+ };
+
render(
, undefined, store);
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1);
});
+ describe('with 0 avatars', () => {
+ beforeEach(() => {
+ store.carousels = {
+ avatars: [],
+ };
+ });
+
+ it('renders the error message', () => {
+ render(
, undefined, store);
+
+ expect(screen.queryAllByTestId('feed-carousel-error')).toHaveLength(0);
+ });
+ });
+
describe('with a failed request to the API', () => {
beforeEach(() => {
store.carousels = {
diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx
index 54bdd960d..d64c6f8f0 100644
--- a/app/soapbox/features/feed-filtering/feed-carousel.tsx
+++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx
@@ -15,13 +15,24 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
const selectedAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId);
const isSelected = avatar.account_id === selectedAccountId;
- const handleClick = () =>
- isSelected
- ? dispatch(replaceHomeTimeline(null, { maxId: null }))
- : dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }));
+ const [isLoading, setLoading] = useState
(false);
+
+ const handleClick = () => {
+ if (isLoading) {
+ return;
+ }
+
+ setLoading(true);
+
+ if (isSelected) {
+ dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false)));
+ } else {
+ dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false)));
+ }
+ };
return (
-
+
{isSelected && (
@@ -41,7 +52,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
/>
- {avatar.acct}
+ {avatar.acct}
);
@@ -93,6 +104,10 @@ const FeedCarousel = () => {
);
}
+ if (avatars.length === 0) {
+ return null;
+ }
+
return (
diff --git a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx
new file mode 100644
index 000000000..683e771b6
--- /dev/null
+++ b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+
+import VerificationBadge from 'soapbox/components/verification_badge';
+import { useAccount, useAppSelector } from 'soapbox/hooks';
+
+import { Card, CardBody, CardTitle, HStack, Stack, Text } from '../../components/ui';
+import ActionButton from '../ui/components/action-button';
+
+import type { Account } from 'soapbox/types/entities';
+
+const messages = defineMessages({
+ heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested profiles' },
+ viewAll: { id: 'feed_suggestions.view_all', defaultMessage: 'View all' },
+});
+
+const SuggestionItem = ({ accountId }: { accountId: string }) => {
+ const account = useAccount(accountId) as Account;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {account.verified && }
+
+
+ @{account.acct}
+
+
+
+
+
+
+ );
+};
+
+const FeedSuggestions = () => {
+ const intl = useIntl();
+ const suggestedProfiles = useAppSelector((state) => state.suggestions.items);
+
+ return (
+
+
+
+
+
+ {intl.formatMessage(messages.viewAll)}
+
+
+
+
+
+ {suggestedProfiles.slice(0, 4).map((suggestedProfile) => (
+
+ ))}
+
+
+
+ );
+};
+
+export default FeedSuggestions;
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/follow-recommendations/index.tsx b/app/soapbox/features/follow-recommendations/index.tsx
new file mode 100644
index 000000000..7fda03c7a
--- /dev/null
+++ b/app/soapbox/features/follow-recommendations/index.tsx
@@ -0,0 +1,82 @@
+import debounce from 'lodash/debounce';
+import React, { useEffect } from 'react';
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import { fetchSuggestions } from 'soapbox/actions/suggestions';
+import ScrollableList from 'soapbox/components/scrollable_list';
+import { Stack, Text } from 'soapbox/components/ui';
+import AccountContainer from 'soapbox/containers/account_container';
+import Column from 'soapbox/features/ui/components/column';
+import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
+
+const messages = defineMessages({
+ heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested profiles' },
+});
+
+const FollowRecommendations: React.FC = () => {
+ const dispatch = useAppDispatch();
+ const intl = useIntl();
+ const features = useFeatures();
+
+ const suggestions = useAppSelector((state) => state.suggestions.items);
+ const hasMore = useAppSelector((state) => !!state.suggestions.next);
+ const isLoading = useAppSelector((state) => state.suggestions.isLoading);
+
+ const handleLoadMore = debounce(() => {
+ if (isLoading) {
+ return null;
+ }
+
+ return dispatch(fetchSuggestions({ limit: 20 }));
+ }, 300);
+
+ useEffect(() => {
+ dispatch(fetchSuggestions({ limit: 20 }));
+ }, []);
+
+ if (suggestions.size === 0 && !isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {features.truthSuggestions ? (
+ suggestions.map((suggestedProfile) => (
+
+ ))
+ ) : (
+ suggestions.map((suggestion) => (
+
+ ))
+ )}
+
+
+
+ );
+};
+
+export default FollowRecommendations;
diff --git a/app/soapbox/features/follow_recommendations/components/account.tsx b/app/soapbox/features/follow_recommendations/components/account.tsx
deleted file mode 100644
index 67bb18f50..000000000
--- a/app/soapbox/features/follow_recommendations/components/account.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import React from 'react';
-
-import Avatar from 'soapbox/components/avatar';
-import DisplayName from 'soapbox/components/display-name';
-import Permalink from 'soapbox/components/permalink';
-import ActionButton from 'soapbox/features/ui/components/action-button';
-import { useAppSelector } from 'soapbox/hooks';
-import { makeGetAccount } from 'soapbox/selectors';
-
-const getAccount = makeGetAccount();
-
-const getFirstSentence = (str: string) => {
- const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/);
-
- return arr[0];
-};
-
-interface IAccount {
- id: string,
-}
-
-const Account: React.FC
= ({ id }) => {
- const account = useAppSelector((state) => getAccount(state, id));
-
- if (!account) return null;
-
- return (
-
-
-
-
-
-
-
- {getFirstSentence(account.get('note_plain'))}
-
-
-
-
-
- );
-};
-
-export default Account;
diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx b/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx
deleted file mode 100644
index c3f198f94..000000000
--- a/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-
-import { Button } from 'soapbox/components/ui';
-
-import FollowRecommendationsList from './follow_recommendations_list';
-
-interface IFollowRecommendationsContainer {
- onDone: () => void,
-}
-
-const FollowRecommendationsContainer: React.FC = ({ onDone }) => (
-
-);
-
-export default FollowRecommendationsContainer;
diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx b/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx
deleted file mode 100644
index e9e295d58..000000000
--- a/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React, { useEffect } from 'react';
-import { FormattedMessage } from 'react-intl';
-import { useDispatch } from 'react-redux';
-
-import { fetchSuggestions } from 'soapbox/actions/suggestions';
-import { Spinner } from 'soapbox/components/ui';
-import { useAppSelector } from 'soapbox/hooks';
-
-import Account from './account';
-
-const FollowRecommendationsList: React.FC = () => {
- const dispatch = useDispatch();
-
- const suggestions = useAppSelector((state) => state.suggestions.items);
- const isLoading = useAppSelector((state) => state.suggestions.isLoading);
-
- useEffect(() => {
- if (suggestions.size === 0) {
- dispatch(fetchSuggestions());
- }
- }, []);
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
- {suggestions.size > 0 ? suggestions.map((suggestion) => (
-
- )) : (
-
-
-
- )}
-
- );
-};
-
-export default FollowRecommendationsList;
diff --git a/app/soapbox/features/follow_recommendations/index.tsx b/app/soapbox/features/follow_recommendations/index.tsx
deleted file mode 100644
index 444504532..000000000
--- a/app/soapbox/features/follow_recommendations/index.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import React from 'react';
-import { useHistory } from 'react-router-dom';
-
-import Column from 'soapbox/features/ui/components/column';
-
-import FollowRecommendationsContainer from './components/follow_recommendations_container';
-
-const FollowRecommendations: React.FC = () => {
- const history = useHistory();
-
- const onDone = () => {
- history.push('/');
- };
-
- return (
-
-
-
- );
-};
-
-export default FollowRecommendations;
diff --git a/app/soapbox/features/home_timeline/index.tsx b/app/soapbox/features/home_timeline/index.tsx
index 3e9fa5346..62b343c9e 100644
--- a/app/soapbox/features/home_timeline/index.tsx
+++ b/app/soapbox/features/home_timeline/index.tsx
@@ -3,6 +3,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { expandHomeTimeline } from 'soapbox/actions/timelines';
+import PullToRefresh from 'soapbox/components/pull-to-refresh';
import { Column, Stack, Text } from 'soapbox/components/ui';
import Timeline from 'soapbox/features/ui/components/timeline';
import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks';
@@ -59,47 +60,48 @@ const HomeTimeline: React.FC = () => {
return (
-
-
-
-
-
-
-
-
-
- {features.federating && (
-
+
+
+
-
-
- ),
- }}
+ id='empty_column.home.title'
+ defaultMessage="You're not following anyone yet"
/>
- )}
-
- }
- />
+
+
+
+
+
+ {features.federating && (
+
+
+
+
+ ),
+ }}
+ />
+
+ )}
+
+ }
+ />
+
);
};
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 5ac0877d1..000000000
--- a/app/soapbox/features/notifications/index.js
+++ /dev/null
@@ -1,208 +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']),
- 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,
- 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 } = this.props;
- const emptyMessage = ;
-
- 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/onboarding/steps/suggested-accounts-step.tsx b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx
index 6b52270c5..d7340b163 100644
--- a/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx
+++ b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx
@@ -45,6 +45,7 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
// @ts-ignore: TS thinks `id` is passed to , but it isn't
id={suggestion.account}
showProfileHoverCard={false}
+ withLinkToProfile={false}
/>
))}
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 (
-