diff --git a/app/soapbox/features/quotes/index.tsx b/app/soapbox/features/quotes/index.tsx
new file mode 100644
index 000000000..a93fc8317
--- /dev/null
+++ b/app/soapbox/features/quotes/index.tsx
@@ -0,0 +1,55 @@
+import { OrderedSet as ImmutableOrderedSet } from 'immutable';
+import { debounce } from 'lodash';
+import React from 'react';
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+import { useDispatch } from 'react-redux';
+import { useParams } from 'react-router-dom';
+
+import { expandStatusQuotes, fetchStatusQuotes } from 'soapbox/actions/status-quotes';
+import StatusList from 'soapbox/components/status-list';
+import { Column } from 'soapbox/components/ui';
+import { useAppSelector } from 'soapbox/hooks';
+
+const messages = defineMessages({
+ heading: { id: 'column.quotes', defaultMessage: 'Post quotes' },
+});
+
+const handleLoadMore = debounce((statusId: string, dispatch: React.Dispatch
) =>
+ dispatch(expandStatusQuotes(statusId)), 300, { leading: true });
+
+const Quotes: React.FC = () => {
+ const dispatch = useDispatch();
+ const intl = useIntl();
+ const { statusId } = useParams<{ statusId: string }>();
+
+ const statusIds = useAppSelector((state) => state.status_lists.getIn([`quotes:${statusId}`, 'items'], ImmutableOrderedSet()));
+ const isLoading = useAppSelector((state) => state.status_lists.getIn([`quotes:${statusId}`, 'isLoading'], true));
+ const hasMore = useAppSelector((state) => !!state.status_lists.getIn([`quotes:${statusId}`, 'next']));
+
+ React.useEffect(() => {
+ dispatch(fetchStatusQuotes(statusId));
+ }, [statusId]);
+
+ const handleRefresh = async() => {
+ await dispatch(fetchStatusQuotes(statusId));
+ };
+
+ const emptyMessage = ;
+
+ return (
+
+ }
+ scrollKey={`quotes:${statusId}`}
+ hasMore={hasMore}
+ isLoading={typeof isLoading === 'boolean' ? isLoading : true}
+ onLoadMore={() => handleLoadMore(statusId, dispatch)}
+ onRefresh={handleRefresh}
+ emptyMessage={emptyMessage}
+ divideType='space'
+ />
+
+ );
+};
+
+export default Quotes;
diff --git a/app/soapbox/features/remote-timeline/index.tsx b/app/soapbox/features/remote-timeline/index.tsx
index a6963df48..67d8c31d5 100644
--- a/app/soapbox/features/remote-timeline/index.tsx
+++ b/app/soapbox/features/remote-timeline/index.tsx
@@ -1,11 +1,10 @@
import React, { useEffect, useRef } from 'react';
-import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { connectRemoteStream } from 'soapbox/actions/streaming';
import { expandRemoteTimeline } from 'soapbox/actions/timelines';
import IconButton from 'soapbox/components/icon-button';
-import SubNavigation from 'soapbox/components/sub-navigation';
import { Column, HStack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useSettings } from 'soapbox/hooks';
@@ -13,10 +12,6 @@ import Timeline from '../ui/components/timeline';
import PinnedHostsPicker from './components/pinned-hosts-picker';
-const messages = defineMessages({
- heading: { id: 'column.remote', defaultMessage: 'Federated timeline' },
-});
-
interface IRemoteTimeline {
params?: {
instance?: string,
@@ -25,7 +20,6 @@ interface IRemoteTimeline {
/** View statuses from a remote instance. */
const RemoteTimeline: React.FC = ({ params }) => {
- const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
@@ -65,25 +59,21 @@ const RemoteTimeline: React.FC = ({ params }) => {
}, [onlyMedia]);
return (
-
-
-
+
+ {instance && }
- {instance && }
-
- {!pinned && (
-
-
-
-
-
-
- )}
-
+ {!pinned && (
+
+
+
+
+
+
+ )}
{
const emptyMessage = ;
return (
-
+
= ({ status }): JSX.Element | null => {
+ const history = useHistory();
+
const me = useAppSelector(({ me }) => me);
const { allowedEmoji } = useSoapboxConfig();
const dispatch = useDispatch();
@@ -81,6 +84,28 @@ const StatusInteractionBar: React.FC = ({ status }): JSX.
return null;
};
+ const navigateToQuotes: React.EventHandler = (e) => {
+ e.preventDefault();
+
+ history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status.id}/quotes`);
+ };
+
+ const getQuotes = () => {
+ if (status.quotes_count) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ };
+
const handleOpenFavouritesModal: React.EventHandler> = (e) => {
e.preventDefault();
@@ -142,6 +167,7 @@ const StatusInteractionBar: React.FC = ({ status }): JSX.
return (
{getReposts()}
+ {getQuotes()}
{features.emojiReacts ? getEmojiReacts() : getFavourites()}
);
diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx
index a413dd9d1..04a8b518e 100644
--- a/app/soapbox/features/status/index.tsx
+++ b/app/soapbox/features/status/index.tsx
@@ -29,7 +29,6 @@ import MissingIndicator from 'soapbox/components/missing-indicator';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import ScrollableList from 'soapbox/components/scrollable-list';
import StatusActionBar from 'soapbox/components/status-action-bar';
-import SubNavigation from 'soapbox/components/sub-navigation';
import Tombstone from 'soapbox/components/tombstone';
import { Column, Stack } from 'soapbox/components/ui';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
@@ -516,11 +515,7 @@ const Thread: React.FC = (props) => {
}
return (
-
-
-
-
-
+
diff --git a/app/soapbox/features/test-timeline/index.tsx b/app/soapbox/features/test-timeline/index.tsx
index 7102d9602..1c64ecad5 100644
--- a/app/soapbox/features/test-timeline/index.tsx
+++ b/app/soapbox/features/test-timeline/index.tsx
@@ -4,7 +4,6 @@ import { useDispatch } from 'react-redux';
import { importFetchedStatuses } from 'soapbox/actions/importer';
import { expandTimelineSuccess } from 'soapbox/actions/timelines';
-import SubNavigation from 'soapbox/components/sub-navigation';
import { Column } from '../../components/ui';
import Timeline from '../ui/components/timeline';
@@ -40,8 +39,7 @@ const TestTimeline: React.FC = () => {
}, []);
return (
-
-
+
-
- {heading &&
}
- {menu && (
-
-
-
- )}
-
- {children}
-
- );
- }
-
-}
diff --git a/app/soapbox/features/ui/components/column-forbidden.tsx b/app/soapbox/features/ui/components/column-forbidden.tsx
index 001c82417..86d4e7ab1 100644
--- a/app/soapbox/features/ui/components/column-forbidden.tsx
+++ b/app/soapbox/features/ui/components/column-forbidden.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
-import Column from './column';
+import { Column } from 'soapbox/components/ui';
const messages = defineMessages({
title: { id: 'column_forbidden.title', defaultMessage: 'Forbidden' },
diff --git a/app/soapbox/features/ui/components/column-header.tsx b/app/soapbox/features/ui/components/column-header.tsx
deleted file mode 100644
index 2c61be235..000000000
--- a/app/soapbox/features/ui/components/column-header.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react';
-
-// import classNames from 'clsx';
-// import Icon from 'soapbox/components/icon';
-import SubNavigation from 'soapbox/components/sub-navigation';
-
-interface IColumnHeader {
- icon?: string,
- type: string
- active?: boolean,
- columnHeaderId?: string,
-}
-
-const ColumnHeader: React.FC = ({ type }) => {
- return ;
-};
-
-export default ColumnHeader;
-
-// export default class ColumnHeader extends React.PureComponent {
-
-// static propTypes = {
-// icon: PropTypes.string,
-// type: PropTypes.string,
-// active: PropTypes.bool,
-// onClick: PropTypes.func,
-// columnHeaderId: PropTypes.string,
-// };
-
-// handleClick = () => {
-// this.props.onClick();
-// }
-
-// render() {
-// const { icon, type, active, columnHeaderId } = this.props;
-
-// return (
-//
-//
-//
-// );
-// }
-
-// }
diff --git a/app/soapbox/features/ui/components/column.tsx b/app/soapbox/features/ui/components/column.tsx
deleted file mode 100644
index 9604049b4..000000000
--- a/app/soapbox/features/ui/components/column.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-
-import Pullable from 'soapbox/components/pullable';
-import { Column } from 'soapbox/components/ui';
-
-import ColumnHeader from './column-header';
-
-import type { IColumn } from 'soapbox/components/ui/column/column';
-
-interface IUIColumn extends IColumn {
- heading?: string,
- icon?: string,
- active?: boolean,
-}
-
-const UIColumn: React.FC = ({
- heading,
- icon,
- children,
- active,
- ...rest
-}) => {
- const columnHeaderId = heading && heading.replace(/ /g, '-');
-
- return (
-
- {heading && }
-
- {children}
-
-
- );
-
-};
-
-export default UIColumn;
diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx
index 8601deaf7..71bd2ed6f 100644
--- a/app/soapbox/features/ui/index.tsx
+++ b/app/soapbox/features/ui/index.tsx
@@ -12,6 +12,7 @@ import { fetchAnnouncements } from 'soapbox/actions/announcements';
import { fetchChats } from 'soapbox/actions/chats';
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
+import { uploadEventBanner } from 'soapbox/actions/events';
import { fetchFilters } from 'soapbox/actions/filters';
import { fetchMarker } from 'soapbox/actions/markers';
import { openModal } from 'soapbox/actions/modals';
@@ -110,9 +111,10 @@ import {
TestTimeline,
LogoutPage,
AuthTokenList,
+ Quotes,
+ ServiceWorkerInfo,
EventInformation,
EventDiscussion,
- ServiceWorkerInfo,
Events,
} from './util/async-components';
import { WrappedRoute } from './util/react-router-helpers';
@@ -120,7 +122,6 @@ import { WrappedRoute } from './util/react-router-helpers';
// Dummy import, to make sure that ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
import 'soapbox/components/status';
-import { uploadEventBanner } from 'soapbox/actions/events';
const EmptyPage = HomePage;
@@ -244,7 +245,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
{features.lists && }
- {features.lists && }
+ {features.lists && }
{features.bookmarks && }
@@ -271,6 +272,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
+
diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts
index daba52eef..842850fbc 100644
--- a/app/soapbox/features/ui/util/async-components.ts
+++ b/app/soapbox/features/ui/util/async-components.ts
@@ -506,6 +506,10 @@ export function AnnouncementsPanel() {
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
}
+export function Quotes() {
+ return import(/*webpackChunkName: "features/quotes" */'../../quotes');
+}
+
export function ComposeEventModal() {
return import(/* webpackChunkName: "features/compose_event_modal" */'../components/modals/compose-event-modal/compose-event-modal');
}
diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts
index 1c3b68f42..3acc8333e 100644
--- a/app/soapbox/normalizers/status.ts
+++ b/app/soapbox/normalizers/status.ts
@@ -60,6 +60,7 @@ export const StatusRecord = ImmutableRecord({
pleroma: ImmutableMap(),
poll: null as EmbeddedEntity,
quote: null as EmbeddedEntity,
+ quotes_count: 0,
reblog: null as EmbeddedEntity,
reblogged: false,
reblogs_count: 0,
@@ -158,6 +159,8 @@ const fixQuote = (status: ImmutableMap) => {
return status.withMutations(status => {
status.update('quote', quote => quote || status.getIn(['pleroma', 'quote']) || null);
status.deleteIn(['pleroma', 'quote']);
+ status.update('quotes_count', quotes_count => quotes_count || status.getIn(['pleroma', 'quotes_count'], 0));
+ status.deleteIn(['pleroma', 'quotes_count']);
});
};
diff --git a/app/soapbox/reducers/status-lists.ts b/app/soapbox/reducers/status-lists.ts
index 1f743ea16..eea737a05 100644
--- a/app/soapbox/reducers/status-lists.ts
+++ b/app/soapbox/reducers/status-lists.ts
@@ -4,6 +4,15 @@ import {
Record as ImmutableRecord,
} from 'immutable';
+import {
+ STATUS_QUOTES_EXPAND_FAIL,
+ STATUS_QUOTES_EXPAND_REQUEST,
+ STATUS_QUOTES_EXPAND_SUCCESS,
+ STATUS_QUOTES_FETCH_FAIL,
+ STATUS_QUOTES_FETCH_REQUEST,
+ STATUS_QUOTES_FETCH_SUCCESS,
+} from 'soapbox/actions/status-quotes';
+
import {
BOOKMARKED_STATUSES_FETCH_REQUEST,
BOOKMARKED_STATUSES_FETCH_SUCCESS,
@@ -59,7 +68,7 @@ import {
import type { AnyAction } from 'redux';
import type { Status as StatusEntity } from 'soapbox/types/entities';
-const StatusListRecord = ImmutableRecord({
+export const StatusListRecord = ImmutableRecord({
next: null as string | null,
loaded: false,
isLoading: null as boolean | null,
@@ -178,6 +187,16 @@ export default function statusLists(state = initialState, action: AnyAction) {
case SCHEDULED_STATUS_CANCEL_REQUEST:
case SCHEDULED_STATUS_CANCEL_SUCCESS:
return removeOneFromList(state, 'scheduled_statuses', action.id || action.status.id);
+ case STATUS_QUOTES_FETCH_REQUEST:
+ case STATUS_QUOTES_EXPAND_REQUEST:
+ return setLoading(state, `quotes:${action.statusId}`, true);
+ case STATUS_QUOTES_FETCH_FAIL:
+ case STATUS_QUOTES_EXPAND_FAIL:
+ return setLoading(state, `quotes:${action.statusId}`, false);
+ case STATUS_QUOTES_FETCH_SUCCESS:
+ return normalizeList(state, `quotes:${action.statusId}`, action.statuses, action.next);
+ case STATUS_QUOTES_EXPAND_SUCCESS:
+ return appendToList(state, `quotes:${action.statusId}`, action.statuses, action.next);
case RECENT_EVENTS_FETCH_REQUEST:
return setLoading(state, 'recent_events', true);
case RECENT_EVENTS_FETCH_FAIL: