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';
@@ -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<QuotedStatusProps> = ({
);
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<QuotedStatusProps> = ({
quoteState === 'unauthorized'
) {
quoteError = (
<FormattedMessage
id='status.quote_error.not_available'
defaultMessage='Post unavailable'
/>
<>
<FormattedMessage
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(
(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 (
<QuotedStatus
quote={quote}
contextType='composer'
onQuoteCancel={!isEditing ? handleQuoteCancel : undefined}
/>
);

View File

@@ -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",

View File

@@ -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,