2
0

Allow removing deleted quotes from composer (#36080)

This commit is contained in:
diondiondion
2025-09-11 16:44:20 +02:00
committed by GitHub
parent 872044484c
commit 84d1ba980b
4 changed files with 140 additions and 66 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@@ -13,7 +13,9 @@ import type { RootState } from 'mastodon/store';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { fetchStatus } from '../actions/statuses'; import { fetchStatus } from '../actions/statuses';
import { makeGetStatus } from '../selectors'; import { makeGetStatusWithExtraInfo } from '../selectors';
import { Button } from './button';
const MAX_QUOTE_POSTS_NESTING_LEVEL = 1; 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 = ( type GetStatusSelector = (
state: RootState, state: RootState,
props: { id?: string | null; contextType?: string }, 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 { interface QuotedStatusProps {
quote: QuoteMap; quote: QuoteMap;
@@ -86,31 +92,41 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
); );
const quotedStatusId = quote.get('quoted_status'); const quotedStatusId = quote.get('quoted_status');
const status = useAppSelector((state) => const getStatusSelector = useMemo(
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined, () => 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(() => { useEffect(() => {
if (shouldLoadQuote && quotedStatusId) { if (isLoaded) {
isFetchingQuoteRef.current = false;
}
}, [isLoaded]);
useEffect(() => {
if (shouldFetchQuote && quotedStatusId && !isFetchingQuoteRef.current) {
dispatch( dispatch(
fetchStatus(quotedStatusId, { fetchStatus(quotedStatusId, {
parentQuotePostId, parentQuotePostId,
alsoFetchContext: false, 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 const isFilteredAndHidden = loadingState === 'filtered';
// 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;
let quoteError: React.ReactNode = null; let quoteError: React.ReactNode = null;
@@ -154,10 +170,20 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
quoteState === 'unauthorized' quoteState === 'unauthorized'
) { ) {
quoteError = ( quoteError = (
<FormattedMessage <>
id='status.quote_error.not_available' <FormattedMessage
defaultMessage='Post unavailable' id='status.quote_error.not_available'
/> defaultMessage='Post unavailable'
/>
{contextType === 'composer' && (
<Button compact plain onClick={onQuoteCancel}>
<FormattedMessage
id='status.remove_quote'
defaultMessage='Remove'
/>
</Button>
)}
</>
); );
} }

View File

@@ -11,7 +11,9 @@ export const ComposeQuotedStatus: FC = () => {
const quotedStatusId = useAppSelector( const quotedStatusId = useAppSelector(
(state) => state.compose.get('quoted_status_id') as string | null, (state) => state.compose.get('quoted_status_id') as string | null,
); );
const isEditing = useAppSelector((state) => !!state.compose.get('id')); const isEditing = useAppSelector((state) => !!state.compose.get('id'));
const quote = useMemo( const quote = useMemo(
() => () =>
quotedStatusId quotedStatusId
@@ -22,16 +24,20 @@ export const ComposeQuotedStatus: FC = () => {
: null, : null,
[quotedStatusId], [quotedStatusId],
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleQuoteCancel = useCallback(() => { const handleQuoteCancel = useCallback(() => {
dispatch(quoteComposeCancel()); dispatch(quoteComposeCancel());
}, [dispatch]); }, [dispatch]);
if (!quote) { if (!quote) {
return null; return null;
} }
return ( return (
<QuotedStatus <QuotedStatus
quote={quote} quote={quote}
contextType='composer'
onQuoteCancel={!isEditing ? handleQuoteCancel : undefined} onQuoteCancel={!isEditing ? handleQuoteCancel : undefined}
/> />
); );

View File

@@ -924,6 +924,7 @@
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",
"status.remove_bookmark": "Remove bookmark", "status.remove_bookmark": "Remove bookmark",
"status.remove_favourite": "Remove from favorites", "status.remove_favourite": "Remove from favorites",
"status.remove_quote": "Remove",
"status.replied_in_thread": "Replied in thread", "status.replied_in_thread": "Replied in thread",
"status.replied_to": "Replied to {name}", "status.replied_to": "Replied to {name}",
"status.reply": "Reply", "status.reply": "Reply",

View File

@@ -8,57 +8,98 @@ import { getFilters } from './filters';
export { makeGetAccount } from "./accounts"; export { makeGetAccount } from "./accounts";
export { getStatusList } from "./statuses"; 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 = () => { export const makeGetStatus = () => {
return createSelector( return createSelector(
[ getStatusInputSelectors,
(state, { id }) => state.getIn(['statuses', id]), (...args) => {
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), const {status} = getStatusResultFunction(...args);
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), return status
(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);
});
}, },
); );
}; };
/**
* 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 = () => { export const makeGetPictureInPicture = () => {
return createSelector([ return createSelector([
(state, { id }) => state.picture_in_picture.statusId === id, (state, { id }) => state.picture_in_picture.statusId === id,