diff --git a/app/javascript/flavours/glitch/actions/interactions_typed.ts b/app/javascript/flavours/glitch/actions/interactions_typed.ts index ac0bd69646..1679c0551e 100644 --- a/app/javascript/flavours/glitch/actions/interactions_typed.ts +++ b/app/javascript/flavours/glitch/actions/interactions_typed.ts @@ -2,11 +2,12 @@ import { apiReblog, apiUnreblog, apiRevokeQuote, + apiGetQuotes, } from 'flavours/glitch/api/interactions'; import type { StatusVisibility } from 'flavours/glitch/models/status'; import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; -import { importFetchedStatus } from './importer'; +import { importFetchedStatus, importFetchedStatuses } from './importer'; export const reblog = createDataLoadingThunk( 'status/reblog', @@ -53,3 +54,19 @@ export const revokeQuote = createDataLoadingThunk( return discardLoadData; }, ); + +export const fetchQuotes = createDataLoadingThunk( + 'status/fetch_quotes', + async ({ statusId, next }: { statusId: string; next?: string }) => { + const { links, statuses } = await apiGetQuotes(statusId, next); + + return { + links, + statuses, + replace: !next, + }; + }, + (payload, { dispatch }) => { + dispatch(importFetchedStatuses(payload.statuses)); + }, +); diff --git a/app/javascript/flavours/glitch/api/interactions.ts b/app/javascript/flavours/glitch/api/interactions.ts index b387b7bbb9..144afc178b 100644 --- a/app/javascript/flavours/glitch/api/interactions.ts +++ b/app/javascript/flavours/glitch/api/interactions.ts @@ -1,4 +1,4 @@ -import { apiRequestPost } from 'flavours/glitch/api'; +import api, { apiRequestPost, getLinks } from 'flavours/glitch/api'; import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses'; import type { StatusVisibility } from 'flavours/glitch/models/status'; @@ -14,3 +14,15 @@ export const apiRevokeQuote = (quotedStatusId: string, statusId: string) => apiRequestPost( `v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`, ); + +export const apiGetQuotes = async (statusId: string, url?: string) => { + const response = await api().request({ + method: 'GET', + url: url ?? `/api/v1/statuses/${statusId}/quotes`, + }); + + return { + statuses: response.data, + links: getLinks(response), + }; +}; diff --git a/app/javascript/flavours/glitch/features/quotes/index.tsx b/app/javascript/flavours/glitch/features/quotes/index.tsx new file mode 100644 index 0000000000..0b3ed96415 --- /dev/null +++ b/app/javascript/flavours/glitch/features/quotes/index.tsx @@ -0,0 +1,113 @@ +import { useCallback, useEffect } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { List as ImmutableList } from 'immutable'; + +import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react'; +import { fetchQuotes } from 'flavours/glitch/actions/interactions_typed'; +import { ColumnHeader } from 'flavours/glitch/components/column_header'; +import { Icon } from 'flavours/glitch/components/icon'; +import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; +import StatusList from 'flavours/glitch/components/status_list'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + refresh: { id: 'refresh', defaultMessage: 'Refresh' }, +}); + +const emptyList = ImmutableList(); + +export const Quotes: React.FC<{ + multiColumn?: boolean; + params?: { statusId: string }; +}> = ({ multiColumn, params }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const statusId = params?.statusId; + + const isCorrectStatusId: boolean = useAppSelector( + (state) => state.status_lists.getIn(['quotes', 'statusId']) === statusId, + ); + const statusIds = useAppSelector((state) => + state.status_lists.getIn(['quotes', 'items'], emptyList), + ); + const nextUrl = useAppSelector( + (state) => + state.status_lists.getIn(['quotes', 'next']) as string | undefined, + ); + const isLoading = useAppSelector((state) => + state.status_lists.getIn(['quotes', 'isLoading'], true), + ); + const hasMore = !!nextUrl; + + useEffect(() => { + if (statusId) void dispatch(fetchQuotes({ statusId })); + }, [dispatch, statusId]); + + const handleLoadMore = useCallback(() => { + if (statusId && isCorrectStatusId && nextUrl) + void dispatch(fetchQuotes({ statusId, next: nextUrl })); + }, [dispatch, statusId, isCorrectStatusId, nextUrl]); + + const handleRefresh = useCallback(() => { + if (statusId) void dispatch(fetchQuotes({ statusId })); + }, [dispatch, statusId]); + + if (!statusIds || !isCorrectStatusId) { + return ( + + + + ); + } + + const emptyMessage = ( + + ); + + return ( + + + + + } + /> + + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Quotes; diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx index 6e8f0f33fd..b33db9bb6e 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx @@ -32,6 +32,7 @@ import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon'; import { Audio } from 'flavours/glitch/features/audio'; import scheduleIdleTask from 'flavours/glitch/features/ui/util/schedule_idle_task'; import { Video } from 'flavours/glitch/features/video'; +import { me } from 'flavours/glitch/initial_state'; import { useAppSelector } from 'flavours/glitch/store'; import Card from './card'; @@ -326,6 +327,22 @@ export const DetailedStatus: React.FC<{ if (['private', 'direct'].includes(status.get('visibility') as string)) { quotesLink = ''; + } else if (status.getIn(['account', 'id']) === me) { + quotesLink = ( + + + + + + + ); } else { quotesLink = ( diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index bd0e49160e..699577bc2b 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -78,6 +78,7 @@ import { PrivacyPolicy, TermsOfService, AccountFeatured, + Quotes, } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; import { focusColumn, getFocusedItemIndex, focusItemSibling } from './util/focusUtils'; @@ -217,6 +218,7 @@ class SwitchingColumnsArea extends PureComponent { + {/* Legacy routes, cannot be easily factored with other routes because they share a param name */} diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js index 7f8f979e37..a8ec268343 100644 --- a/app/javascript/flavours/glitch/features/ui/util/async-components.js +++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js @@ -86,6 +86,10 @@ export function Favourites () { return import('../../favourites'); } +export function Quotes () { + return import('../../quotes'); +} + export function FollowRequests () { return import('../../follow_requests'); } diff --git a/app/javascript/flavours/glitch/reducers/status_lists.js b/app/javascript/flavours/glitch/reducers/status_lists.js index c9d39130ee..447dde6ecb 100644 --- a/app/javascript/flavours/glitch/reducers/status_lists.js +++ b/app/javascript/flavours/glitch/reducers/status_lists.js @@ -28,6 +28,9 @@ import { PIN_SUCCESS, UNPIN_SUCCESS, } from '../actions/interactions'; +import { + fetchQuotes +} from '../actions/interactions_typed'; import { PINNED_STATUSES_FETCH_SUCCESS, } from '../actions/pin_statuses'; @@ -40,8 +43,6 @@ import { TRENDS_STATUSES_EXPAND_FAIL, } from '../actions/trends'; - - const initialState = ImmutableMap({ favourites: ImmutableMap({ next: null, @@ -63,6 +64,12 @@ const initialState = ImmutableMap({ loaded: false, items: ImmutableOrderedSet(), }), + quotes: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableOrderedSet(), + statusId: null, + }), }); const normalizeList = (state, listType, statuses, next) => { @@ -147,6 +154,13 @@ export default function statusLists(state = initialState, action) { case muteAccountSuccess.type: return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.payload.statuses.getIn([statusId, 'account']) === action.payload.relationship.id)); default: - return state; + if (fetchQuotes.fulfilled.match(action)) + return normalizeList(state, 'quotes', action.payload.statuses, action.payload.next).set('statusId', action.meta.arg.statusId); + else if (fetchQuotes.pending.match(action)) + return state.setIn(['quotes', 'isLoading'], true).setIn(['quotes', 'statusId'], action.meta.arg.statusId); + else if (fetchQuotes.rejected.match(action)) + return state.setIn(['quotes', 'isLoading', false]).setIn(['quotes', 'statusId'], action.meta.arg.statusId); + else + return state; } }