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