diff --git a/app/soapbox/__fixtures__/status-quotes.json b/app/soapbox/__fixtures__/status-quotes.json
new file mode 100644
index 0000000000..d74a149c95
--- /dev/null
+++ b/app/soapbox/__fixtures__/status-quotes.json
@@ -0,0 +1,15 @@
+[
+ {
+ "account": {
+ "id": "ABDSjI3Q0R8aDaz1U0"
+ },
+ "content": "quoast",
+ "id": "AJsajx9hY4Q7IKQXEe",
+ "pleroma": {
+ "quote": {
+ "content": "
10
",
+ "id": "AJmoVikzI3SkyITyim"
+ }
+ }
+ }
+]
diff --git a/app/soapbox/actions/__tests__/status-quotes.test.ts b/app/soapbox/actions/__tests__/status-quotes.test.ts
new file mode 100644
index 0000000000..1e68dc882b
--- /dev/null
+++ b/app/soapbox/actions/__tests__/status-quotes.test.ts
@@ -0,0 +1,150 @@
+import { Map as ImmutableMap } from 'immutable';
+
+import { __stub } from 'soapbox/api';
+import { mockStore, rootState } from 'soapbox/jest/test-helpers';
+import { StatusListRecord } from 'soapbox/reducers/status-lists';
+
+import { fetchStatusQuotes, expandStatusQuotes } from '../status-quotes';
+
+const status = {
+ account: {
+ id: 'ABDSjI3Q0R8aDaz1U0',
+ },
+ content: 'quoast',
+ id: 'AJsajx9hY4Q7IKQXEe',
+ pleroma: {
+ quote: {
+ content: '10
',
+ id: 'AJmoVikzI3SkyITyim',
+ },
+ },
+};
+
+const statusId = 'AJmoVikzI3SkyITyim';
+
+describe('fetchStatusQuotes()', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ const state = rootState.set('me', '1234');
+ store = mockStore(state);
+ });
+
+ describe('with a successful API request', () => {
+ beforeEach(() => {
+ const quotes = require('soapbox/__fixtures__/status-quotes.json');
+
+ __stub((mock) => {
+ mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).reply(200, quotes, {
+ link: `; rel='prev'`,
+ });
+ });
+ });
+
+ it('should fetch quotes from the API', async() => {
+ const expectedActions = [
+ { type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
+ { type: 'POLLS_IMPORT', polls: [] },
+ { type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
+ { type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false },
+ { type: 'STATUS_QUOTES_FETCH_SUCCESS', statusId, statuses: [status], next: null },
+ ];
+ await store.dispatch(fetchStatusQuotes(statusId));
+ const actions = store.getActions();
+
+ expect(actions).toEqual(expectedActions);
+ });
+ });
+
+ describe('with an unsuccessful API request', () => {
+ beforeEach(() => {
+ __stub((mock) => {
+ mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).networkError();
+ });
+ });
+
+ it('should dispatch failed action', async() => {
+ const expectedActions = [
+ { type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
+ { type: 'STATUS_QUOTES_FETCH_FAIL', statusId, error: new Error('Network Error') },
+ ];
+ await store.dispatch(fetchStatusQuotes(statusId));
+ const actions = store.getActions();
+
+ expect(actions).toEqual(expectedActions);
+ });
+ });
+});
+
+describe('expandStatusQuotes()', () => {
+ let store: ReturnType;
+
+ describe('without a url', () => {
+ beforeEach(() => {
+ const state = rootState
+ .set('me', '1234')
+ .set('status_lists', ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: null }) }));
+ store = mockStore(state);
+ });
+
+ it('should do nothing', async() => {
+ await store.dispatch(expandStatusQuotes(statusId));
+ const actions = store.getActions();
+
+ expect(actions).toEqual([]);
+ });
+ });
+
+ describe('with a url', () => {
+ beforeEach(() => {
+ const state = rootState.set('me', '1234')
+ .set('status_lists', ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: 'example' }) }));
+ store = mockStore(state);
+ });
+
+ describe('with a successful API request', () => {
+ beforeEach(() => {
+ const quotes = require('soapbox/__fixtures__/status-quotes.json');
+
+ __stub((mock) => {
+ mock.onGet('example').reply(200, quotes, {
+ link: `; rel='prev'`,
+ });
+ });
+ });
+
+ it('should fetch quotes from the API', async() => {
+ const expectedActions = [
+ { type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
+ { type: 'POLLS_IMPORT', polls: [] },
+ { type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
+ { type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false },
+ { type: 'STATUS_QUOTES_EXPAND_SUCCESS', statusId, statuses: [status], next: null },
+ ];
+ await store.dispatch(expandStatusQuotes(statusId));
+ const actions = store.getActions();
+
+ expect(actions).toEqual(expectedActions);
+ });
+ });
+
+ describe('with an unsuccessful API request', () => {
+ beforeEach(() => {
+ __stub((mock) => {
+ mock.onGet('example').networkError();
+ });
+ });
+
+ it('should dispatch failed action', async() => {
+ const expectedActions = [
+ { type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
+ { type: 'STATUS_QUOTES_EXPAND_FAIL', statusId, error: new Error('Network Error') },
+ ];
+ await store.dispatch(expandStatusQuotes(statusId));
+ const actions = store.getActions();
+
+ expect(actions).toEqual(expectedActions);
+ });
+ });
+ });
+});
diff --git a/app/soapbox/actions/status-quotes.ts b/app/soapbox/actions/status-quotes.ts
new file mode 100644
index 0000000000..9dab8df467
--- /dev/null
+++ b/app/soapbox/actions/status-quotes.ts
@@ -0,0 +1,75 @@
+import api, { getLinks } from '../api';
+
+import { importFetchedStatuses } from './importer';
+
+import type { AppDispatch, RootState } from 'soapbox/store';
+
+export const STATUS_QUOTES_FETCH_REQUEST = 'STATUS_QUOTES_FETCH_REQUEST';
+export const STATUS_QUOTES_FETCH_SUCCESS = 'STATUS_QUOTES_FETCH_SUCCESS';
+export const STATUS_QUOTES_FETCH_FAIL = 'STATUS_QUOTES_FETCH_FAIL';
+
+export const STATUS_QUOTES_EXPAND_REQUEST = 'STATUS_QUOTES_EXPAND_REQUEST';
+export const STATUS_QUOTES_EXPAND_SUCCESS = 'STATUS_QUOTES_EXPAND_SUCCESS';
+export const STATUS_QUOTES_EXPAND_FAIL = 'STATUS_QUOTES_EXPAND_FAIL';
+
+const noOp = () => new Promise(f => f(null));
+
+export const fetchStatusQuotes = (statusId: string) =>
+ (dispatch: AppDispatch, getState: () => RootState) => {
+ if (getState().status_lists.getIn([`quotes:${statusId}`, 'isLoading'])) {
+ return dispatch(noOp);
+ }
+
+ dispatch({
+ statusId,
+ type: STATUS_QUOTES_FETCH_REQUEST,
+ });
+
+ return api(getState).get(`/api/v1/pleroma/statuses/${statusId}/quotes`).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(importFetchedStatuses(response.data));
+ return dispatch({
+ type: STATUS_QUOTES_FETCH_SUCCESS,
+ statusId,
+ statuses: response.data,
+ next: next ? next.uri : null,
+ });
+ }).catch(error => {
+ dispatch({
+ type: STATUS_QUOTES_FETCH_FAIL,
+ statusId,
+ error,
+ });
+ });
+ };
+
+export const expandStatusQuotes = (statusId: string) =>
+ (dispatch: AppDispatch, getState: () => RootState) => {
+ const url = getState().status_lists.getIn([`quotes:${statusId}`, 'next'], null) as string | null;
+
+ if (url === null || getState().status_lists.getIn([`quotes:${statusId}`, 'isLoading'])) {
+ return dispatch(noOp);
+ }
+
+ dispatch({
+ type: STATUS_QUOTES_EXPAND_REQUEST,
+ statusId,
+ });
+
+ return api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(importFetchedStatuses(response.data));
+ dispatch({
+ type: STATUS_QUOTES_EXPAND_SUCCESS,
+ statusId,
+ statuses: response.data,
+ next: next ? next.uri : null,
+ });
+ }).catch(error => {
+ dispatch({
+ type: STATUS_QUOTES_EXPAND_FAIL,
+ statusId,
+ error,
+ });
+ });
+ };
diff --git a/app/soapbox/features/quotes/index.tsx b/app/soapbox/features/quotes/index.tsx
new file mode 100644
index 0000000000..a93fc8317f
--- /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/status/components/status-interaction-bar.tsx b/app/soapbox/features/status/components/status-interaction-bar.tsx
index f888cb059b..ef22ba14bb 100644
--- a/app/soapbox/features/status/components/status-interaction-bar.tsx
+++ b/app/soapbox/features/status/components/status-interaction-bar.tsx
@@ -3,6 +3,7 @@ import { List as ImmutableList } from 'immutable';
import React from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { useDispatch } from 'react-redux';
+import { useHistory } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import { HStack, Text, Emoji } from 'soapbox/components/ui';
@@ -16,6 +17,8 @@ interface IStatusInteractionBar {
}
const StatusInteractionBar: React.FC = ({ 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/ui/index.tsx b/app/soapbox/features/ui/index.tsx
index c280aafb1d..6e6b7427bf 100644
--- a/app/soapbox/features/ui/index.tsx
+++ b/app/soapbox/features/ui/index.tsx
@@ -109,6 +109,7 @@ import {
TestTimeline,
LogoutPage,
AuthTokenList,
+ Quotes,
ServiceWorkerInfo,
} from './util/async-components';
import { WrappedRoute } from './util/react-router-helpers';
@@ -265,6 +266,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 23f221ceae..c7d0e29467 100644
--- a/app/soapbox/features/ui/util/async-components.ts
+++ b/app/soapbox/features/ui/util/async-components.ts
@@ -505,3 +505,7 @@ export function FamiliarFollowersModal() {
export function AnnouncementsPanel() {
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
}
+
+export function Quotes() {
+ return import(/*webpackChunkName: "features/quotes" */'../../quotes');
+}
diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts
index 41ebacfc73..120a4b62bf 100644
--- a/app/soapbox/normalizers/status.ts
+++ b/app/soapbox/normalizers/status.ts
@@ -45,6 +45,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,
@@ -142,6 +143,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 3a19c5eea4..64373f0e20 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,
@@ -51,7 +60,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,
@@ -168,6 +177,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);
default:
return state;
}