Allow removing deleted quotes from composer (#36080)
This commit is contained in:
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user