2
0

Change paste-link-to-quote loading state from generic loading bar to compose placeholder (#36695)

This commit is contained in:
Claire
2025-11-04 20:20:39 +01:00
parent ba498ae779
commit cfdd9396c0
5 changed files with 99 additions and 13 deletions

View File

@@ -14,6 +14,7 @@ import {
import type { ApiQuotePolicy } from '../api_types/quotes';
import type { Status, StatusVisibility } from '../models/status';
import type { RootState } from '../store';
import { showAlert } from './alerts';
import { changeCompose, focusCompose } from './compose';
@@ -212,6 +213,17 @@ export const quoteComposeById = createAppThunk(
},
);
const composeStateForbidsLink = (composeState: RootState['compose']) => {
return (
composeState.get('quoted_status_id') ||
composeState.get('is_submitting') ||
composeState.get('poll') ||
composeState.get('is_uploading') ||
composeState.get('id') ||
composeState.get('privacy') === 'direct'
);
};
export const pasteLinkCompose = createDataLoadingThunk(
'compose/pasteLink',
async ({ url }: { url: string }) => {
@@ -222,16 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk(
limit: 2,
});
},
(data, { dispatch, getState }) => {
(data, { dispatch, getState, requestId }) => {
const composeState = getState().compose;
if (
composeState.get('quoted_status_id') ||
composeState.get('is_submitting') ||
composeState.get('poll') ||
composeState.get('is_uploading') ||
composeState.get('id') ||
composeState.get('privacy') === 'direct'
composeStateForbidsLink(composeState) ||
composeState.get('fetching_link') !== requestId // Request has been cancelled
)
return;
@@ -247,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk(
dispatch(quoteComposeById(data.statuses[0].id));
}
},
{
useLoadingBar: false,
condition: (_, { getState }) =>
!getState().compose.get('fetching_link') &&
!composeStateForbidsLink(getState().compose),
},
);
// Ideally this would cancel the action and the HTTP request, but this is good enough
export const cancelPasteLinkCompose = createAction(
'compose/cancelPasteLinkCompose',
);
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { cancelPasteLinkCompose } from '@/mastodon/actions/compose_typed';
import { useAppDispatch } from '@/mastodon/store';
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import { DisplayName } from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button';
import { Skeleton } from 'mastodon/components/skeleton';
const messages = defineMessages({
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
});
export const QuotePlaceholder: FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleQuoteCancel = useCallback(() => {
dispatch(cancelPasteLinkCompose());
}, [dispatch]);
return (
<div className='status__quote'>
<div className='status'>
<div className='status__info'>
<div className='status__avatar'>
<Skeleton width='32px' height='32px' />
</div>
<div className='status__display-name'>
<DisplayName />
</div>
<IconButton
onClick={handleQuoteCancel}
className='status__quote-cancel'
title={intl.formatMessage(messages.quote_cancel)}
icon='cancel-fill'
iconComponent={CancelFillIcon}
/>
</div>
<div className='status__content'>
<Skeleton />
</div>
</div>
</div>
);
};

View File

@@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
import { QuotedStatus } from '@/mastodon/components/status_quoted';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { QuotePlaceholder } from './quote_placeholder';
export const ComposeQuotedStatus: FC = () => {
const quotedStatusId = useAppSelector(
(state) => state.compose.get('quoted_status_id') as string | null,
);
const isFetchingLink = useAppSelector(
(state) => !!state.compose.get('fetching_link'),
);
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
const quote = useMemo(
@@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => {
dispatch(quoteComposeCancel());
}, [dispatch]);
if (!quote) {
if (isFetchingLink && !quote) {
return <QuotePlaceholder />;
} else if (!quote) {
return null;
}

View File

@@ -6,6 +6,8 @@ import {
quoteCompose,
quoteComposeCancel,
setComposeQuotePolicy,
pasteLinkCompose,
cancelPasteLinkCompose,
} from '@/mastodon/actions/compose_typed';
import { timelineDelete } from 'mastodon/actions/timelines_typed';
@@ -93,6 +95,7 @@ const initialState = ImmutableMap({
quoted_status_id: null,
quote_policy: 'public',
default_quote_policy: 'public', // Set in hydration.
fetching_link: null,
});
const initialPoll = ImmutableMap({
@@ -350,6 +353,12 @@ export const composeReducer = (state = initialState, action) => {
return state.set('quoted_status_id', null);
} else if (setComposeQuotePolicy.match(action)) {
return state.set('quote_policy', action.payload);
} else if (pasteLinkCompose.pending.match(action)) {
return state.set('fetching_link', action.meta.requestId);
} else if (pasteLinkCompose.fulfilled.match(action) || pasteLinkCompose.rejected.match(action)) {
return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state;
} else if (cancelPasteLinkCompose.match(action)) {
return state.set('fetching_link', null);
}
switch(action.type) {

View File

@@ -42,7 +42,7 @@ interface AppThunkConfig {
}
export type AppThunkApi = Pick<
GetThunkAPI<AppThunkConfig>,
'getState' | 'dispatch'
'getState' | 'dispatch' | 'requestId'
>;
interface AppThunkOptions<Arg> {
@@ -60,7 +60,7 @@ type AppThunk<Arg = void, Returned = void> = (
type AppThunkCreator<Arg = void, Returned = void, ExtraArg = unknown> = (
arg: Arg,
api: AppThunkApi,
api: Pick<AppThunkApi, 'getState' | 'dispatch'>,
extra?: ExtraArg,
) => Returned;
@@ -143,10 +143,10 @@ export function createAsyncThunk<Arg = void, Returned = void>(
name,
async (
arg: Arg,
{ getState, dispatch, fulfillWithValue, rejectWithValue },
{ getState, dispatch, requestId, fulfillWithValue, rejectWithValue },
) => {
try {
const result = await creator(arg, { dispatch, getState });
const result = await creator(arg, { dispatch, getState, requestId });
return fulfillWithValue(result, {
useLoadingBar: options.useLoadingBar,
@@ -280,10 +280,11 @@ export function createDataLoadingThunk<
return createAsyncThunk<Args, Returned>(
name,
async (arg, { getState, dispatch }) => {
async (arg, { getState, dispatch, requestId }) => {
const data = await loadData(arg, {
dispatch,
getState,
requestId,
});
if (!onData) return data as Returned;
@@ -291,6 +292,7 @@ export function createDataLoadingThunk<
const result = await onData(data, {
dispatch,
getState,
requestId,
discardLoadData: discardLoadDataInPayload,
actionArg: arg,
});