From 2399266e54469d8cab576665f2089c3ce4ee5b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 17 Sep 2025 20:42:34 +0200 Subject: [PATCH] pl-fe: allow redacting posts by admins, when supported by backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- packages/pl-api/lib/client.ts | 2 +- packages/pl-fe/src/actions/admin.ts | 18 +++++++++++ packages/pl-fe/src/actions/compose.ts | 30 +++++++++++++++---- packages/pl-fe/src/actions/statuses.ts | 16 +++++++--- .../src/components/status-action-bar.tsx | 15 ++++++++++ .../compose/components/compose-form.tsx | 22 ++++++++++++++ packages/pl-fe/src/locales/en.json | 5 ++++ packages/pl-fe/src/modals/compose-modal.tsx | 5 ++-- packages/pl-fe/src/reducers/compose.ts | 11 +++++++ 9 files changed, 112 insertions(+), 12 deletions(-) diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index f914cecfe..18982a5df 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -4402,7 +4402,7 @@ class PlApiClient { * Requires features{@link Features.pleromaAdminStatusesRedact} */ redactStatus: async (statusId: string, params: EditStatusParams & { overwrite?: boolean }) => { - const response = await this.request(`/api/v1/pleroma/admin/statuses/${statusId}/redact`, { method: 'PUT', body: params }); + const response = await this.request(`/api/v1/pleroma/admin/statuses/${statusId}/redact`, { method: 'PATCH', body: params }); return v.parse(statusSchema, response.json); }, diff --git a/packages/pl-fe/src/actions/admin.ts b/packages/pl-fe/src/actions/admin.ts index 52cfcfbbc..ff578df8a 100644 --- a/packages/pl-fe/src/actions/admin.ts +++ b/packages/pl-fe/src/actions/admin.ts @@ -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({ 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, }; diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts index 55f39887d..0491b4be5 100644 --- a/packages/pl-fe/src/actions/compose.ts +++ b/packages/pl-fe/src/actions/compose.ts @@ -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 @@ -1056,7 +1073,8 @@ type ComposeAction = | ReturnType | ReturnType | ReturnType - | ReturnType; + | ReturnType + | ReturnType; 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, diff --git a/packages/pl-fe/src/actions/statuses.ts b/packages/pl-fe/src/actions/statuses.ts index c97f88823..75f6216a8 100644 --- a/packages/pl-fe/src/actions/statuses.ts +++ b/packages/pl-fe/src/actions/statuses.ts @@ -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({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId }); + if (!params.preview) dispatch({ 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 } diff --git a/packages/pl-fe/src/components/status-action-bar.tsx b/packages/pl-fe/src/components/status-action-bar.tsx index 2f39f2c3b..411031005 100644 --- a/packages/pl-fe/src/components/status-action-bar.tsx +++ b/packages/pl-fe/src/components/status-action-bar.tsx @@ -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 = ({ } }; + const handleRedactStatus: React.EventHandler = () => { + dispatch(redactStatus(status.id)); + }; + const menu: Menu = []; if (expandable) { @@ -1088,6 +1094,15 @@ const MenuButton: React.FC = ({ }); } + 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), diff --git a/packages/pl-fe/src/features/compose/components/compose-form.tsx b/packages/pl-fe/src/features/compose/components/compose-form.tsx index a49e676b1..84d8273f4 100644 --- a/packages/pl-fe/src/features/compose/components/compose-form.tsx +++ b/packages/pl-fe/src/features/compose/components/compose-form.tsx @@ -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, shouldCondense, autoFocus, clickab dispatch(ignoreClearLinkSuggestion(id, key)); }; + const handleChangeRedactingOverwrite: React.ChangeEventHandler = (e) => { + dispatch(changeComposeRedactingOverwrite(id, e.target.checked)); + }; + useEffect(() => { document.addEventListener('click', handleClick, true); @@ -438,6 +445,21 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab + + {compose.redacting && ( + + } + hint={} + > + + + + )} ); diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index 102f39aba..37e939ec9 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -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", diff --git a/packages/pl-fe/src/modals/compose-modal.tsx b/packages/pl-fe/src/modals/compose-modal.tsx index 98eae4e24..cfae161f6 100644 --- a/packages/pl-fe/src/modals/compose-modal.tsx +++ b/packages/pl-fe/src/modals/compose-modal.tsx @@ -70,8 +70,9 @@ const ComposeModal: React.FC = ({ onClose, c const renderTitle = () => { if (compose.draft_id) { return ; - } - if (statusId) { + } else if (compose.redacting) { + return ; + } else if (statusId) { return ; } else if (privacy === 'direct') { return ; diff --git a/packages/pl-fe/src/reducers/compose.ts b/packages/pl-fe/src/reducers/compose.ts index 50eaa631f..9774eb10c 100644 --- a/packages/pl-fe/src/reducers/compose.ts +++ b/packages/pl-fe/src/reducers/compose.ts @@ -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 | null; hashtag_casing_suggestion: string | null; hashtag_casing_suggestion_ignored: boolean | null; + redacting: boolean; + redactingOverwrite: boolean; } const newCompose = (params: Partial = {}): Compose => ({ @@ -192,6 +195,8 @@ const newCompose = (params: Partial = {}): 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; }