fix(edit-menu): let moderators edit their own post content

Show a content editor in the mod menu when the signed-in mod is the post
author, and publish author edits alongside moderation actions on save.
This commit is contained in:
Tommaso Casaburi
2026-06-27 18:41:28 +07:00
parent fcd2171e62
commit fa5320e834
3 changed files with 98 additions and 6 deletions
@@ -376,6 +376,8 @@ describe('EditMenu', () => {
await renderMenu(basePost); await renderMenu(basePost);
await openMenu(); await openMenu();
expect(getLabelCheckbox('Edit?')).toBeNull();
await click(getCheckbox('removed')); await click(getCheckbox('removed'));
await click(getCheckbox('purged')); await click(getCheckbox('purged'));
await click(getCheckbox('locked')); await click(getCheckbox('locked'));
@@ -448,6 +450,41 @@ describe('EditMenu', () => {
expect(testState.modOptions?.commentModeration?.author).toBeUndefined(); expect(testState.modOptions?.commentModeration?.author).toBeUndefined();
}); });
it('lets moderators edit their own post content from the mod menu', async () => {
testState.privileges = {
isAccountCommentAuthor: true,
isAccountMod: true,
isCommentAuthorMod: false,
};
await renderMenu(basePost);
await openMenu();
const editCheckbox = getLabelCheckbox('Edit?');
expect(editCheckbox).not.toBeNull();
await click(editCheckbox);
const textarea = container.querySelector('textarea');
expect(textarea).not.toBeNull();
await dispatchInput(textarea as HTMLTextAreaElement, 'Updated by moderator');
await clickButton('save');
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
expect(testState.publishCommentModerationMock).toHaveBeenCalledOnce();
expect(testState.authorOptions).toMatchObject({
author: {
address: '0xauthor',
displayName: 'Alice',
},
commentCid: 'comment-1',
content: 'Updated by moderator',
deleted: false,
communityAddress: 'music-posting.eth',
});
});
it('does not enable purge when the confirmation is rejected', async () => { it('does not enable purge when the confirmation is rejected', async () => {
confirmSpy.mockReturnValue(false); confirmSpy.mockReturnValue(false);
testState.privileges = { testState.privileges = {
+10 -3
View File
@@ -42,7 +42,7 @@
} }
.menuItem input[type="text"] { .menuItem input[type="text"], .editTextarea {
padding: 2px; padding: 2px;
box-shadow: var(--box-shadow-input); box-shadow: var(--box-shadow-input);
margin-top: 2px; margin-top: 2px;
@@ -50,12 +50,12 @@
margin-bottom: 2px; margin-bottom: 2px;
} }
.menuItem input[type="text"], .menuItem input[type='number'] { .menuItem input[type="text"], .menuItem input[type='number'], .editTextarea {
border: var(--post-form-field-input-border); border: var(--post-form-field-input-border);
outline: none; outline: none;
} }
.menuItem input[type="text"]:focus, .menuItem input[type='number']:focus { .menuItem input[type="text"]:focus, .menuItem input[type='number']:focus, .editTextarea:focus {
border: var(--post-form-field-input-focus-border); border: var(--post-form-field-input-focus-border);
} }
@@ -64,6 +64,13 @@
display: block; display: block;
} }
.editTextarea {
width: calc(100% - 12px);
height: 100px;
margin-left: 3px;
font-family: inherit;
}
.bottom button { .bottom button {
text-transform: capitalize; text-transform: capitalize;
margin-left: 2px; margin-left: 2px;
+51 -3
View File
@@ -34,7 +34,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const resolvedPost = withResolvedCommentCommunityAddress(post); const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler } = resolvedPost || {}; const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler } = resolvedPost || {};
const communityAddress = getCommentCommunityAddress(resolvedPost); const communityAddress = getCommentCommunityAddress(resolvedPost);
const archived = isCommentArchived(resolvedPost); const archived = isCommentArchived(resolvedPost);
const authorDisplayName = resolvedPost?.author?.displayName; const authorDisplayName = resolvedPost?.author?.displayName;
@@ -52,7 +52,9 @@ const EditMenu = ({ post }: { post: Comment }) => {
const allowsPseudonymousDelete = pseudonymityMode !== undefined && pseudonymityMode !== 'none'; const allowsPseudonymousDelete = pseudonymityMode !== undefined && pseudonymityMode !== 'none';
const canAttemptAuthorDelete = isAccountCommentAuthor || allowsPseudonymousDelete; const canAttemptAuthorDelete = isAccountCommentAuthor || allowsPseudonymousDelete;
const canOpenEditMenu = isAccountMod || canAttemptAuthorDelete; const canOpenEditMenu = isAccountMod || canAttemptAuthorDelete;
const canEditOwnModPost = isAccountMod && isAccountCommentAuthor;
const signer = isAccountCommentAuthor ? account?.signer : undefined; const signer = isAccountCommentAuthor ? account?.signer : undefined;
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
const latestPostRef = useRef(resolvedPost); const latestPostRef = useRef(resolvedPost);
useEffect(() => { useEffect(() => {
latestPostRef.current = resolvedPost; latestPostRef.current = resolvedPost;
@@ -64,6 +66,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
commentCid: cid, commentCid: cid,
communityAddress, communityAddress,
// Author edit properties // Author edit properties
content: canEditOwnModPost ? content : undefined,
deleted: canAttemptAuthorDelete ? (deleted ?? false) : undefined, deleted: canAttemptAuthorDelete ? (deleted ?? false) : undefined,
// Mod edit properties // Mod edit properties
commentModeration: isAccountMod commentModeration: isAccountMod
@@ -87,9 +90,11 @@ const EditMenu = ({ post }: { post: Comment }) => {
}; };
}, [ }, [
isAccountMod, isAccountMod,
canEditOwnModPost,
canAttemptAuthorDelete, canAttemptAuthorDelete,
archived, archived,
cid, cid,
content,
deleted, deleted,
locked, locked,
pinned, pinned,
@@ -109,6 +114,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
const options: PublishCommentEditOptions = { const options: PublishCommentEditOptions = {
commentCid: cid, commentCid: cid,
communityAddress, communityAddress,
content: canEditOwnModPost ? publishCommentEditOptions.content : undefined,
deleted: publishCommentEditOptions.deleted, deleted: publishCommentEditOptions.deleted,
onChallenge, onChallenge,
onChallengeVerification: alertChallengeVerificationFailed, onChallengeVerification: alertChallengeVerificationFailed,
@@ -127,7 +133,18 @@ const EditMenu = ({ post }: { post: Comment }) => {
signer, signer,
author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author, author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author,
}; };
}, [publishCommentEditOptions, cid, communityAddress, isAccountCommentAuthor, signer, account?.author, author?.address, authorDisplayName, onChallenge]); }, [
publishCommentEditOptions,
cid,
communityAddress,
canEditOwnModPost,
isAccountCommentAuthor,
signer,
account?.author,
author?.address,
authorDisplayName,
onChallenge,
]);
const modEditOptions = useMemo<PublishCommentModerationOptions>( const modEditOptions = useMemo<PublishCommentModerationOptions>(
() => ({ () => ({
@@ -163,6 +180,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
const resetMenuState = () => { const resetMenuState = () => {
setPublishCommentEditOptions(defaultPublishEditOptions); setPublishCommentEditOptions(defaultPublishEditOptions);
setIsContentEditorOpen(false);
setBanDuration( setBanDuration(
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1, defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
); );
@@ -250,9 +268,10 @@ const EditMenu = ({ post }: { post: Comment }) => {
const headingId = useId(); const headingId = useId();
const hasDeleteStateChanged = (deleted ?? false) !== (publishCommentEditOptions.deleted ?? false); const hasDeleteStateChanged = (deleted ?? false) !== (publishCommentEditOptions.deleted ?? false);
const hasContentStateChanged = canEditOwnModPost && (content ?? '') !== (publishCommentEditOptions.content ?? '');
const _publishCommentEdit = async () => { const _publishCommentEdit = async () => {
const shouldPublishAuthorEdit = canAttemptAuthorDelete && hasDeleteStateChanged; const shouldPublishAuthorEdit = (canAttemptAuthorDelete && hasDeleteStateChanged) || hasContentStateChanged;
try { try {
if (shouldPublishAuthorEdit && isAccountMod) { if (shouldPublishAuthorEdit && isAccountMod) {
@@ -318,6 +337,35 @@ const EditMenu = ({ post }: { post: Comment }) => {
)} )}
{isAccountMod && ( {isAccountMod && (
<> <>
{canEditOwnModPost && (
<>
<div className={styles.menuItem}>
<label>
[
<input
aria-label={capitalize(t('edit'))}
type='checkbox'
onChange={() => setIsContentEditorOpen((isOpen) => !isOpen)}
checked={isContentEditorOpen}
/>
{capitalize(t('edit'))}?]
</label>
</div>
{isContentEditorOpen && (
<div>
<textarea
className={styles.editTextarea}
aria-label={capitalize(t('edit'))}
value={publishCommentEditOptions.content || ''}
onChange={(e) => {
const newContent = e.target.value;
setPublishCommentEditOptions((state) => ({ ...state, content: newContent }));
}}
/>
</div>
)}
</>
)}
<div className={styles.menuItem}> <div className={styles.menuItem}>
<label> <label>
[ [