Add ability to list quotes of one's own posts (#35914)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Crowdin / Upload translations / upload-translations (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Haml Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled

This commit is contained in:
Claire 2025-08-27 17:38:52 +02:00 committed by GitHub
parent 9c55b2fbe4
commit 02de05dc27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 185 additions and 5 deletions

View File

@ -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));
},
);

View File

@ -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<ApiStatusJSON>(
`v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
);
export const apiGetQuotes = async (statusId: string, url?: string) => {
const response = await api().request<ApiStatusJSON[]>({
method: 'GET',
url: url ?? `/api/v1/statuses/${statusId}/quotes`,
});
return {
statuses: response.data,
links: getLinks(response),
};
};

View File

@ -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 (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = (
<FormattedMessage
id='status.quotes.empty'
defaultMessage='No one has quoted this post yet. When someone does, it will show up here.'
/>
);
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
showBackButton
multiColumn={multiColumn}
extraButton={
<button
type='button'
className='column-header__button'
title={intl.formatMessage(messages.refresh)}
aria-label={intl.formatMessage(messages.refresh)}
onClick={handleRefresh}
>
<Icon id='refresh' icon={RefreshIcon} />
</button>
}
/>
<StatusList
scrollKey='quotes_timeline'
statusIds={statusIds}
onLoadMore={handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Quotes;

View File

@ -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 = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/quotes`}
className='detailed-status__link'
>
<span className='detailed-status__quotes'>
<AnimatedNumber value={status.get('quotes_count')} />
</span>
<FormattedMessage
id='status.quotes'
defaultMessage='{count, plural, one {quote} other {quotes}}'
values={{ count: status.get('quotes_count') }}
/>
</Link>
);
} else {
quotesLink = (
<span className='detailed-status__link'>

View File

@ -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 {
<WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/@:acct/:statusId/quotes' component={Quotes} content={children} />
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />

View File

@ -86,6 +86,10 @@ export function Favourites () {
return import('../../favourites');
}
export function Quotes () {
return import('../../quotes');
}
export function FollowRequests () {
return import('../../follow_requests');
}

View File

@ -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",

View File

@ -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;
}
}