From cfdd9396c0f9c54a9ed0feeddb74bd71c5e2971e Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 4 Nov 2025 20:20:39 +0100 Subject: [PATCH] Change paste-link-to-quote loading state from generic loading bar to compose placeholder (#36695) --- .../mastodon/actions/compose_typed.ts | 33 ++++++++++--- .../compose/components/quote_placeholder.tsx | 48 +++++++++++++++++++ .../compose/components/quoted_post.tsx | 10 +++- app/javascript/mastodon/reducers/compose.js | 9 ++++ .../mastodon/store/typed_functions.ts | 12 +++-- 5 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 app/javascript/mastodon/features/compose/components/quote_placeholder.tsx diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index c7bc4ba0c..6b38b25c2 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -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'); diff --git a/app/javascript/mastodon/features/compose/components/quote_placeholder.tsx b/app/javascript/mastodon/features/compose/components/quote_placeholder.tsx new file mode 100644 index 000000000..706594e9c --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/quote_placeholder.tsx @@ -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 ( +
+
+
+
+ +
+
+ +
+ +
+
+ +
+
+
+ ); +}; diff --git a/app/javascript/mastodon/features/compose/components/quoted_post.tsx b/app/javascript/mastodon/features/compose/components/quoted_post.tsx index f09d6fcd3..8be3c7e62 100644 --- a/app/javascript/mastodon/features/compose/components/quoted_post.tsx +++ b/app/javascript/mastodon/features/compose/components/quoted_post.tsx @@ -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 ; + } else if (!quote) { return null; } diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 17b9de5fb..e4cb9ac0e 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -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) { diff --git a/app/javascript/mastodon/store/typed_functions.ts b/app/javascript/mastodon/store/typed_functions.ts index 4d7341b0c..5ceb05909 100644 --- a/app/javascript/mastodon/store/typed_functions.ts +++ b/app/javascript/mastodon/store/typed_functions.ts @@ -42,7 +42,7 @@ interface AppThunkConfig { } export type AppThunkApi = Pick< GetThunkAPI, - 'getState' | 'dispatch' + 'getState' | 'dispatch' | 'requestId' >; interface AppThunkOptions { @@ -60,7 +60,7 @@ type AppThunk = ( type AppThunkCreator = ( arg: Arg, - api: AppThunkApi, + api: Pick, extra?: ExtraArg, ) => Returned; @@ -143,10 +143,10 @@ export function createAsyncThunk( 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( 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, });