diff --git a/app/javascript/mastodon/actions/interactions_typed.ts b/app/javascript/mastodon/actions/interactions_typed.ts index 832ea189104..36f9f85b9cc 100644 --- a/app/javascript/mastodon/actions/interactions_typed.ts +++ b/app/javascript/mastodon/actions/interactions_typed.ts @@ -2,11 +2,12 @@ import { apiReblog, apiUnreblog, apiRevokeQuote, + apiGetQuotes, } from 'mastodon/api/interactions'; import type { StatusVisibility } from 'mastodon/models/status'; import { createDataLoadingThunk } from 'mastodon/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/mastodon/api/interactions.ts b/app/javascript/mastodon/api/interactions.ts index 4db40f6462d..36aaeef1866 100644 --- a/app/javascript/mastodon/api/interactions.ts +++ b/app/javascript/mastodon/api/interactions.ts @@ -1,4 +1,4 @@ -import { apiRequestPost } from 'mastodon/api'; +import api, { apiRequestPost, getLinks } from 'mastodon/api'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; import type { StatusVisibility } from 'mastodon/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/mastodon/features/quotes/index.tsx b/app/javascript/mastodon/features/quotes/index.tsx new file mode 100644 index 00000000000..d0290e11185 --- /dev/null +++ b/app/javascript/mastodon/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 'mastodon/actions/interactions_typed'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { Icon } from 'mastodon/components/icon'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import StatusList from 'mastodon/components/status_list'; +import { useAppDispatch, useAppSelector } from 'mastodon/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/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx index 652eab73846..957364fd7a7 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.tsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx @@ -31,6 +31,7 @@ import { VisibilityIcon } from 'mastodon/components/visibility_icon'; import { Audio } from 'mastodon/features/audio'; import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task'; import { Video } from 'mastodon/features/video'; +import { me } from 'mastodon/initial_state'; import Card from './card'; @@ -282,6 +283,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/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index f06cf6d36e5..79f7f8c3b24 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -75,6 +75,7 @@ import { PrivacyPolicy, TermsOfService, AccountFeatured, + Quotes, } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; import { focusColumn, getFocusedItemIndex, focusItemSibling } from './util/focusUtils'; @@ -209,6 +210,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/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index d865f7d7a53..6cd46837c0e 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/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/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 0adeaf0896d..9cf14b63b80 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -904,6 +904,7 @@ "status.quote_post_author": "Quoted a post by @{name}", "status.quote_private": "Private posts cannot be quoted", "status.quotes": "{count, plural, one {quote} other {quotes}}", + "status.quotes.empty": "No one has quoted this post yet. When someone does, it will show up here.", "status.read_more": "Read more", "status.reblog": "Boost", "status.reblog_private": "Boost with original visibility", diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index c9d39130ee3..447dde6ecb4 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/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; } }