From 443b960067d32715f0ba345079854d214f9734ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 29 Nov 2022 23:32:21 +0000 Subject: [PATCH] Show quoted statuses list --- app/soapbox/__fixtures__/status-quotes.json | 15 ++ .../actions/__tests__/status-quotes.test.ts | 150 ++++++++++++++++++ app/soapbox/actions/status-quotes.ts | 75 +++++++++ app/soapbox/features/quotes/index.tsx | 55 +++++++ .../components/status-interaction-bar.tsx | 26 +++ app/soapbox/features/ui/index.tsx | 2 + .../features/ui/util/async-components.ts | 4 + app/soapbox/normalizers/status.ts | 3 + app/soapbox/reducers/status-lists.ts | 21 ++- 9 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 app/soapbox/__fixtures__/status-quotes.json create mode 100644 app/soapbox/actions/__tests__/status-quotes.test.ts create mode 100644 app/soapbox/actions/status-quotes.ts create mode 100644 app/soapbox/features/quotes/index.tsx diff --git a/app/soapbox/__fixtures__/status-quotes.json b/app/soapbox/__fixtures__/status-quotes.json new file mode 100644 index 000000000..d74a149c9 --- /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 000000000..1e68dc882 --- /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 000000000..9dab8df46 --- /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 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/status/components/status-interaction-bar.tsx b/app/soapbox/features/status/components/status-interaction-bar.tsx index f888cb059..ef22ba14b 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 c280aafb1..6e6b7427b 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 23f221cea..c7d0e2946 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 41ebacfc7..120a4b62b 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 3a19c5eea..64373f0e2 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; }