diff --git a/app/javascript/mastodon/components/status_quoted.tsx b/app/javascript/mastodon/components/status_quoted.tsx index 3ac720bbc..f0bbe8e7f 100644 --- a/app/javascript/mastodon/components/status_quoted.tsx +++ b/app/javascript/mastodon/components/status_quoted.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -13,7 +13,9 @@ import type { RootState } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { fetchStatus } from '../actions/statuses'; -import { makeGetStatus } from '../selectors'; +import { makeGetStatusWithExtraInfo } from '../selectors'; + +import { Button } from './button'; const MAX_QUOTE_POSTS_NESTING_LEVEL = 1; @@ -55,11 +57,15 @@ const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => { ); }; -type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>; type GetStatusSelector = ( state: RootState, props: { id?: string | null; contextType?: string }, -) => Status | null; +) => { + status: Status | null; + loadingState: 'not-found' | 'loading' | 'filtered' | 'complete'; +}; + +type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>; interface QuotedStatusProps { quote: QuoteMap; @@ -86,31 +92,41 @@ export const QuotedStatus: React.FC = ({ ); const quotedStatusId = quote.get('quoted_status'); - const status = useAppSelector((state) => - quotedStatusId ? state.statuses.get(quotedStatusId) : undefined, + const getStatusSelector = useMemo( + () => makeGetStatusWithExtraInfo() as GetStatusSelector, + [], + ); + const { status, loadingState } = useAppSelector((state) => + getStatusSelector(state, { id: quotedStatusId, contextType }), ); - const shouldLoadQuote = !status?.get('isLoading') && quoteState !== 'deleted'; + const shouldFetchQuote = + !status?.get('isLoading') && + quoteState !== 'deleted' && + loadingState === 'not-found'; + const isLoaded = loadingState === 'complete'; + + const isFetchingQuoteRef = useRef(false); useEffect(() => { - if (shouldLoadQuote && quotedStatusId) { + if (isLoaded) { + isFetchingQuoteRef.current = false; + } + }, [isLoaded]); + + useEffect(() => { + if (shouldFetchQuote && quotedStatusId && !isFetchingQuoteRef.current) { dispatch( fetchStatus(quotedStatusId, { parentQuotePostId, alsoFetchContext: false, }), ); + isFetchingQuoteRef.current = true; } - }, [shouldLoadQuote, quotedStatusId, parentQuotePostId, dispatch]); + }, [shouldFetchQuote, quotedStatusId, parentQuotePostId, dispatch]); - // In order to find out whether the quoted post should be completely hidden - // due to a matching filter, we run it through the selector used by `status_container`. - // If this returns null even though `status` exists, it's because it's filtered. - const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector; - const statusWithExtraData = useAppSelector((state) => - getStatus(state, { id: quotedStatusId, contextType }), - ); - const isFilteredAndHidden = status && statusWithExtraData === null; + const isFilteredAndHidden = loadingState === 'filtered'; let quoteError: React.ReactNode = null; @@ -154,10 +170,20 @@ export const QuotedStatus: React.FC = ({ quoteState === 'unauthorized' ) { quoteError = ( - + <> + + {contextType === 'composer' && ( + + )} + ); } diff --git a/app/javascript/mastodon/features/compose/components/quoted_post.tsx b/app/javascript/mastodon/features/compose/components/quoted_post.tsx index 335e7ce61..f09d6fcd3 100644 --- a/app/javascript/mastodon/features/compose/components/quoted_post.tsx +++ b/app/javascript/mastodon/features/compose/components/quoted_post.tsx @@ -11,7 +11,9 @@ export const ComposeQuotedStatus: FC = () => { const quotedStatusId = useAppSelector( (state) => state.compose.get('quoted_status_id') as string | null, ); + const isEditing = useAppSelector((state) => !!state.compose.get('id')); + const quote = useMemo( () => quotedStatusId @@ -22,16 +24,20 @@ export const ComposeQuotedStatus: FC = () => { : null, [quotedStatusId], ); + const dispatch = useAppDispatch(); const handleQuoteCancel = useCallback(() => { dispatch(quoteComposeCancel()); }, [dispatch]); + if (!quote) { return null; } + return ( ); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 3d8b957c9..c296c4f53 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -924,6 +924,7 @@ "status.redraft": "Delete & re-draft", "status.remove_bookmark": "Remove bookmark", "status.remove_favourite": "Remove from favorites", + "status.remove_quote": "Remove", "status.replied_in_thread": "Replied in thread", "status.replied_to": "Replied to {name}", "status.reply": "Reply", diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 3119b285b..1e68d49aa 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -8,57 +8,98 @@ import { getFilters } from './filters'; export { makeGetAccount } from "./accounts"; export { getStatusList } from "./statuses"; +const getStatusInputSelectors = [ + (state, { id }) => state.getIn(['statuses', id]), + (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + getFilters, + (_, { contextType }) => ['detailed', 'bookmarks', 'favourites'].includes(contextType), +]; + +function getStatusResultFunction( + statusBase, + statusReblog, + accountBase, + accountReblog, + filters, + warnInsteadOfHide +) { + if (!statusBase) { + return { + status: null, + loadingState: 'not-found', + }; + } + + if (statusBase.get('isLoading')) { + return { + status: null, + loadingState: 'loading', + } + } + + if (statusReblog) { + statusReblog = statusReblog.set('account', accountReblog); + } else { + statusReblog = null; + } + + let filtered = false; + let mediaFiltered = false; + if ((accountReblog || accountBase).get('id') !== me && filters) { + let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList(); + if (!warnInsteadOfHide && filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { + return { + status: null, + loadingState: 'filtered', + } + } + + let mediaFilters = filterResults.filter(result => filters.getIn([result.get('filter'), 'filter_action']) === 'blur'); + if (!mediaFilters.isEmpty()) { + mediaFiltered = mediaFilters.map(result => filters.getIn([result.get('filter'), 'title'])); + } + + filterResults = filterResults.filter(result => filters.has(result.get('filter')) && filters.getIn([result.get('filter'), 'filter_action']) !== 'blur'); + if (!filterResults.isEmpty()) { + filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); + } + } + + return { + status: statusBase.withMutations(map => { + map.set('reblog', statusReblog); + map.set('account', accountBase); + map.set('matched_filters', filtered); + map.set('matched_media_filters', mediaFiltered); + }), + loadingState: 'complete' + }; +} + export const makeGetStatus = () => { return createSelector( - [ - (state, { id }) => state.getIn(['statuses', id]), - (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), - (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), - (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), - getFilters, - (_, { contextType }) => ['detailed', 'bookmarks', 'favourites'].includes(contextType), - ], - - (statusBase, statusReblog, accountBase, accountReblog, filters, warnInsteadOfHide) => { - if (!statusBase || statusBase.get('isLoading')) { - return null; - } - - if (statusReblog) { - statusReblog = statusReblog.set('account', accountReblog); - } else { - statusReblog = null; - } - - let filtered = false; - let mediaFiltered = false; - if ((accountReblog || accountBase).get('id') !== me && filters) { - let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList(); - if (!warnInsteadOfHide && filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { - return null; - } - - let mediaFilters = filterResults.filter(result => filters.getIn([result.get('filter'), 'filter_action']) === 'blur'); - if (!mediaFilters.isEmpty()) { - mediaFiltered = mediaFilters.map(result => filters.getIn([result.get('filter'), 'title'])); - } - - filterResults = filterResults.filter(result => filters.has(result.get('filter')) && filters.getIn([result.get('filter'), 'filter_action']) !== 'blur'); - if (!filterResults.isEmpty()) { - filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); - } - } - - return statusBase.withMutations(map => { - map.set('reblog', statusReblog); - map.set('account', accountBase); - map.set('matched_filters', filtered); - map.set('matched_media_filters', mediaFiltered); - }); + getStatusInputSelectors, + (...args) => { + const {status} = getStatusResultFunction(...args); + return status }, ); }; +/** + * This selector extends the `makeGetStatus` with a more detailed + * `loadingState`, which is useful to find out why `null` is returned + * for the `status` field + */ +export const makeGetStatusWithExtraInfo = () => { + return createSelector( + getStatusInputSelectors, + getStatusResultFunction, + ); +}; + export const makeGetPictureInPicture = () => { return createSelector([ (state, { id }) => state.picture_in_picture.statusId === id,