pl-fe: allow redacting posts by admins, when supported by backend

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-09-17 20:42:34 +02:00
parent 2bb7ba020a
commit 2399266e54
9 changed files with 112 additions and 12 deletions

View File

@ -1,8 +1,11 @@
import { importEntities } from 'pl-fe/actions/importer';
import { useModalsStore } from 'pl-fe/stores/modals';
import { filterBadges, getTagDiff } from 'pl-fe/utils/badges';
import { getClient } from '../api';
import { setComposeToStatus } from './compose';
import { STATUS_FETCH_SOURCE_FAIL, type StatusesAction } from './statuses';
import { deleteFromTimelines } from './timelines';
import type { PleromaConfig } from 'pl-api';
@ -118,6 +121,20 @@ const setRole = (accountId: string, role: 'user' | 'moderator' | 'admin') =>
}
};
const redactStatus = (statusId: string) => (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const status = state.statuses[statusId]!;
const poll = status.poll_id ? state.polls[status.poll_id] : undefined;
return getClient(state).statuses.getStatusSource(statusId).then(response => {
dispatch(setComposeToStatus(status, poll, response.text, response.spoiler_text, response.content_type, false, undefined, undefined, true));
useModalsStore.getState().openModal('COMPOSE');
}).catch(error => {
dispatch<StatusesAction>({ type: STATUS_FETCH_SOURCE_FAIL, error });
});
};
type AdminActions =
| { type: typeof ADMIN_CONFIG_FETCH_SUCCESS; configs: PleromaConfig['configs']; needsReboot: boolean }
| { type: typeof ADMIN_CONFIG_UPDATE_REQUEST; configs: PleromaConfig['configs'] }
@ -136,5 +153,6 @@ export {
toggleStatusSensitivity,
setBadges,
setRole,
redactStatus,
type AdminActions,
};

View File

@ -104,12 +104,15 @@ const COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE = 'COMPOSE_CLEAR_LINK_SUGGESTION_IGNO
const COMPOSE_HASHTAG_CASING_SUGGESTION_SET = 'COMPOSE_HASHTAG_CASING_SUGGESTION_SET' as const;
const COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE = 'COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE' as const;
const COMPOSE_REDACTING_OVERWRITE_CHANGE = 'COMPOSE_REDACTING_OVERWRITE_CHANGE' as const;
const getAccount = makeGetAccount();
const messages = defineMessages({
scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' },
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent!' },
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
redactSuccess: { id: 'compose.redact_success', defaultMessage: 'The post was redacted' },
scheduledSuccess: { id: 'compose.scheduled_success', defaultMessage: 'Your post was scheduled' },
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
@ -130,6 +133,7 @@ interface ComposeSetStatusAction {
withRedraft?: boolean;
draftId?: string;
editorState?: string | null;
redacting?: boolean;
}
const setComposeToStatus = (
@ -141,6 +145,7 @@ const setComposeToStatus = (
withRedraft?: boolean,
draftId?: string,
editorState?: string | null,
redacting?: boolean,
) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const { features } = getClient(getState);
@ -158,6 +163,7 @@ const setComposeToStatus = (
withRedraft,
draftId,
editorState,
redacting,
});
};
@ -299,7 +305,7 @@ const directComposeById = (accountId: string) =>
useModalsStore.getState().openModal('COMPOSE');
};
const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, composeId: string, data: BaseStatus | ScheduledStatus, status: string, edit?: boolean) => {
const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, composeId: string, data: BaseStatus | ScheduledStatus, status: string, edit?: boolean, redact?: boolean) => {
if (!dispatch || !getState) return;
const state = getState();
@ -310,7 +316,7 @@ const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, c
dispatch(submitComposeSuccess(composeId, data, accountUrl, draftId));
if (data.scheduled_at === null) {
dispatch(insertIntoTagHistory(composeId, data.tags || [], status));
toast.success(edit ? messages.editSuccess : messages.success, {
toast.success(redact ? messages.redactSuccess : edit ? messages.editSuccess : messages.success, {
actionLabel: messages.view,
actionLink: (data.visibility === 'direct' && getClient(getState()).features.conversations) ? '/conversations' : `/@${data.account.acct}/posts/${data.id}`,
});
@ -456,8 +462,13 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}, preview
onSuccess?.();
}).catch(() => {});
} else {
return dispatch(createStatus(params, idempotencyKey, statusId)).then((data) => {
handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId);
if (compose.redacting) {
// @ts-ignore
params.overwrite = compose.redactingOverwrite;
}
return dispatch(createStatus(params, idempotencyKey, statusId, compose.redacting)).then((data) => {
handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId, compose.redacting);
onSuccess?.();
}).catch((error) => {
dispatch(submitComposeFail(composeId, error));
@ -998,6 +1009,12 @@ const ignoreHashtagCasingSuggestion = (composeId: string) => ({
composeId,
});
const changeComposeRedactingOverwrite = (composeId: string, value: boolean) => ({
type: COMPOSE_REDACTING_OVERWRITE_CHANGE,
composeId,
value,
});
type ComposeAction =
ComposeSetStatusAction
| ReturnType<typeof changeCompose>
@ -1056,7 +1073,8 @@ type ComposeAction =
| ReturnType<typeof suggestClearLink>
| ReturnType<typeof ignoreClearLinkSuggestion>
| ReturnType<typeof suggestHashtagCasing>
| ReturnType<typeof ignoreHashtagCasingSuggestion>;
| ReturnType<typeof ignoreHashtagCasingSuggestion>
| ReturnType<typeof changeComposeRedactingOverwrite>;
export {
COMPOSE_CHANGE,
@ -1117,6 +1135,7 @@ export {
COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE,
COMPOSE_HASHTAG_CASING_SUGGESTION_SET,
COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE,
COMPOSE_REDACTING_OVERWRITE_CHANGE,
setComposeToStatus,
replyCompose,
cancelReplyCompose,
@ -1169,6 +1188,7 @@ export {
cancelPreviewCompose,
suggestHashtagCasing,
ignoreHashtagCasingSuggestion,
changeComposeRedactingOverwrite,
type ComposeReplyAction,
type ComposeSuggestionSelectAction,
type ComposeAction,

View File

@ -36,11 +36,19 @@ const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS' as const;
const STATUS_UNFILTER = 'STATUS_UNFILTER' as const;
const createStatus = (params: CreateStatusParams, idempotencyKey: string, statusId: string | null) =>
const createStatus = (params: CreateStatusParams, idempotencyKey: string, statusId: string | null, redacting = false) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!params.preview) dispatch<StatusesAction>({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId });
if (!params.preview) dispatch<StatusesAction>({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId, redacting });
return (statusId === null ? getClient(getState()).statuses.createStatus(params) : getClient(getState()).statuses.editStatus(statusId, params))
const client = getClient(getState());
return (
statusId === null
? client.statuses.createStatus(params)
: redacting
? client.admin.statuses.redactStatus(statusId, params)
: client.statuses.editStatus(statusId, params)
)
.then((status) => {
if (params.preview) return status;
@ -241,7 +249,7 @@ const unfilterStatus = (statusId: string) => ({
});
type StatusesAction =
| { type: typeof STATUS_CREATE_REQUEST; params: CreateStatusParams; idempotencyKey: string; editing: boolean }
| { type: typeof STATUS_CREATE_REQUEST; params: CreateStatusParams; idempotencyKey: string; editing: boolean; redacting: boolean }
| { type: typeof STATUS_CREATE_SUCCESS; status: BaseStatus | ScheduledStatus; params: CreateStatusParams; idempotencyKey: string; editing: boolean }
| { type: typeof STATUS_CREATE_FAIL; error: unknown; params: CreateStatusParams; idempotencyKey: string; editing: boolean }
| { type: typeof STATUS_FETCH_SOURCE_REQUEST }

View File

@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory, useRouteMatch } from 'react-router-dom';
import { blockAccount } from 'pl-fe/actions/accounts';
import { redactStatus } from 'pl-fe/actions/admin';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'pl-fe/actions/compose';
import { emojiReact, unEmojiReact } from 'pl-fe/actions/emoji-reacts';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'pl-fe/actions/moderation';
@ -100,6 +101,7 @@ const messages = defineMessages({
reblog_visibility_public: { id: 'status.reblog_visibility_public', defaultMessage: 'Public repost' },
reblog_visibility_unlisted: { id: 'status.reblog_visibility_unlisted', defaultMessage: 'Unlisted repost' },
reblog_visibility_private: { id: 'status.reblog_visibility_private', defaultMessage: 'Followers-only repost' },
redact: { id: 'status.redact', defaultMessage: 'Redact' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' },
@ -825,6 +827,10 @@ const MenuButton: React.FC<IMenuButton> = ({
}
};
const handleRedactStatus: React.EventHandler<React.MouseEvent> = () => {
dispatch(redactStatus(status.id));
};
const menu: Menu = [];
if (expandable) {
@ -1088,6 +1094,15 @@ const MenuButton: React.FC<IMenuButton> = ({
});
}
if (isAdmin && features.pleromaAdminStatusesRedact) {
menu.push({
text: intl.formatMessage(messages.redact),
action: handleRedactStatus,
icon: require('@tabler/icons/outline/pencil.svg'),
destructive: true,
});
}
if (!ownAccount) {
menu.push({
text: intl.formatMessage(messages.deleteStatus),

View File

@ -14,13 +14,16 @@ import {
ignoreClearLinkSuggestion,
suggestClearLink,
resetCompose,
changeComposeRedactingOverwrite,
} from 'pl-fe/actions/compose';
import { saveDraftStatus } from 'pl-fe/actions/draft-statuses';
import DropdownMenu from 'pl-fe/components/dropdown-menu';
import List, { ListItem } from 'pl-fe/components/list';
import HStack from 'pl-fe/components/ui/hstack';
import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack';
import SvgIcon from 'pl-fe/components/ui/svg-icon';
import Toggle from 'pl-fe/components/ui/toggle';
import EmojiPickerDropdown from 'pl-fe/features/emoji/containers/emoji-picker-dropdown-container';
import { ComposeEditor } from 'pl-fe/features/ui/util/async-components';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
@ -285,6 +288,10 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
dispatch(ignoreClearLinkSuggestion(id, key));
};
const handleChangeRedactingOverwrite: React.ChangeEventHandler<HTMLInputElement> = (e) => {
dispatch(changeComposeRedactingOverwrite(id, e.target.checked));
};
useEffect(() => {
document.addEventListener('click', handleClick, true);
@ -438,6 +445,21 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
<ComposeButton type='submit' icon={publishIcon} text={publishText} disabled={!canSubmit} actionsMenu={actionsMenu} />
</HStack>
{compose.redacting && (
<List>
<ListItem
className='mt-2'
label={<FormattedMessage id='compose.redact.overwrite_label' defaultMessage='Overwrite existing status' />}
hint={<FormattedMessage id='compose.redact.overwrite_hint' defaultMessage='This will replace the status with a new one, without keeping edit history. The update will not federate.' />}
>
<Toggle
checked={compose.redactingOverwrite}
onChange={handleChangeRedactingOverwrite}
/>
</ListItem>
</List>
)}
</div>
</Stack>
);

View File

@ -505,6 +505,9 @@
"compose.language_dropdown.prompt": "Select language",
"compose.language_dropdown.search": "Search language…",
"compose.language_dropdown.suggestion": "{language} (detected)",
"compose.redact.overwrite_hint": "This will replace the status with a new one, without keeping edit history. The update will not federate.",
"compose.redact.overwrite_label": "Overwrite existing status",
"compose.redact_success": "The post was redacted",
"compose.reply_group_indicator.message": "Posting to {groupLink}",
"compose.scheduled_success": "Your post was scheduled",
"compose.submit_success": "Your post was sent!",
@ -1235,6 +1238,7 @@
"navigation_bar.compose_group": "Compose to group",
"navigation_bar.compose_group_reply": "Reply to group post",
"navigation_bar.compose_quote": "Quote post",
"navigation_bar.compose_redact": "Redact post",
"navigation_bar.compose_reply": "Reply to post",
"navigation_bar.create_event": "Create new event",
"navigation_bar.create_group": "Create group",
@ -1705,6 +1709,7 @@
"status.reblogged_by_private": "{name} reposted to followers",
"status.reblogged_by_with_group": "{name} reposted from {group}",
"status.reblogs.empty": "No one has reposted this post yet. When someone does, they will show up here.",
"status.redact": "Redact",
"status.redraft": "Delete & re-draft",
"status.remove_account_from_group": "Remove account from group",
"status.remove_post_from_group": "Remove post from group",

View File

@ -70,8 +70,9 @@ const ComposeModal: React.FC<BaseModalProps & ComposeModalProps> = ({ onClose, c
const renderTitle = () => {
if (compose.draft_id) {
return <FormattedMessage id='navigation_bar.compose_draft' defaultMessage='Edit draft post' />;
}
if (statusId) {
} else if (compose.redacting) {
return <FormattedMessage id='navigation_bar.compose_redact' defaultMessage='Redact post' />;
} else if (statusId) {
return <FormattedMessage id='navigation_bar.compose_edit' defaultMessage='Edit post' />;
} else if (privacy === 'direct') {
return <FormattedMessage id='navigation_bar.compose_direct' defaultMessage='Direct message' />;

View File

@ -64,6 +64,7 @@ import {
COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE,
type ComposeAction,
type ComposeSuggestionSelectAction,
COMPOSE_REDACTING_OVERWRITE_CHANGE,
} from '../actions/compose';
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, type MeAction } from '../actions/me';
@ -146,6 +147,8 @@ interface Compose {
preview: Partial<BaseStatus> | null;
hashtag_casing_suggestion: string | null;
hashtag_casing_suggestion_ignored: boolean | null;
redacting: boolean;
redactingOverwrite: boolean;
}
const newCompose = (params: Partial<Compose> = {}): Compose => ({
@ -192,6 +195,8 @@ const newCompose = (params: Partial<Compose> = {}): Compose => ({
preview: null,
hashtag_casing_suggestion: null,
hashtag_casing_suggestion_ignored: null,
redacting: false,
redactingOverwrite: false,
...params,
});
@ -569,6 +574,8 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | In
compose.media_attachments = action.status.media_attachments;
compose.sensitive = action.status.sensitive;
compose.redacting = action.redacting || false;
if (action.status.spoiler_text.length > 0) {
compose.spoiler_text = action.status.spoiler_text;
} else {
@ -749,6 +756,10 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | In
compose.hashtag_casing_suggestion = null;
compose.hashtag_casing_suggestion_ignored = true;
});
case COMPOSE_REDACTING_OVERWRITE_CHANGE:
return updateCompose(state, action.composeId, compose => {
compose.redactingOverwrite = action.value;
});
default:
return state;
}