feat: unmoderate content

This commit is contained in:
Danish Arora 2025-09-10 17:28:03 +05:30
parent 6d76962ed9
commit c6e9997c62
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
10 changed files with 585 additions and 13 deletions

View File

@ -25,6 +25,7 @@ interface CommentCardProps {
cellId?: string;
canModerate: boolean;
onModerateComment: (commentId: string) => void;
onUnmoderateComment?: (commentId: string) => void;
onModerateUser: (userAddress: string) => void;
}
@ -48,6 +49,7 @@ const CommentCard: React.FC<CommentCardProps> = ({
cellId,
canModerate,
onModerateComment,
onUnmoderateComment,
onModerateUser,
}) => {
const { voteComment, isVoting } = useForumActions();
@ -155,6 +157,23 @@ const CommentCard: React.FC<CommentCardProps> = ({
</TooltipContent>
</Tooltip>
)}
{canModerate && comment.moderated && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-cyber-neutral hover:text-green-500"
onClick={() => onUnmoderateComment?.(comment.id)}
>
Unmoderate
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Unmoderate comment</p>
</TooltipContent>
</Tooltip>
)}
{cellId && canModerate && (
<Tooltip>
<TooltipTrigger asChild>

View File

@ -39,6 +39,7 @@ const PostDetail = () => {
createComment,
votePost,
moderateComment,
unmoderateComment,
moderateUser,
isCreatingComment,
isVoting,
@ -126,6 +127,13 @@ const PostDetail = () => {
await moderateComment(cell.id, commentId, reason);
};
const handleUnmoderateComment = async (commentId: string) => {
const reason =
window.prompt('Optional note for unmoderation?') || undefined;
if (!cell) return;
await unmoderateComment(cell.id, commentId, reason);
};
const handleModerateUser = async (userAddress: string) => {
const reason =
window.prompt('Reason for moderating this user? (optional)') || undefined;
@ -319,6 +327,7 @@ const PostDetail = () => {
cellId={cell?.id}
canModerate={canModerate(cell?.id || '')}
onModerateComment={handleModerateComment}
onUnmoderateComment={handleUnmoderateComment}
onModerateUser={handleModerateUser}
/>
))

View File

@ -45,6 +45,7 @@ const PostList = () => {
createPost,
votePost,
moderatePost,
unmoderatePost,
moderateUser,
refreshData,
isCreatingPost,
@ -143,6 +144,13 @@ const PostList = () => {
await moderatePost(cell.id, postId, reason);
};
const handleUnmoderate = async (postId: string) => {
const reason =
window.prompt('Optional note for unmoderation?') || undefined;
if (!cell) return;
await unmoderatePost(cell.id, postId, reason);
};
const handleModerateUser = async (userAddress: string) => {
const reason =
window.prompt('Reason for moderating this user? (optional)') || undefined;
@ -352,10 +360,22 @@ const PostList = () => {
</TooltipContent>
</Tooltip>
)}
{post.moderated && (
<span className="ml-2 text-xs text-red-500">
[Moderated]
</span>
{canModerate(cell.id) && post.moderated && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-cyber-neutral hover:text-green-500"
onClick={() => handleUnmoderate(post.id)}
>
Unmoderate
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Unmoderate post</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>

View File

@ -15,7 +15,6 @@ import {
import { localDatabase } from '@/lib/database/LocalDatabase';
import { useAppKitAccount, useDisconnect, modal } from '@reown/appkit/react';
interface AuthContextType {
currentUser: User | null;
isAuthenticating: boolean;

View File

@ -70,18 +70,36 @@ interface ForumContextType {
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
unmoderatePost: (
cellId: string,
postId: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
moderateComment: (
cellId: string,
commentId: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
unmoderateComment: (
cellId: string,
commentId: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
moderateUser: (
cellId: string,
userAddress: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
unmoderateUser: (
cellId: string,
userAddress: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
}
const ForumContext = createContext<ForumContextType | undefined>(undefined);
@ -563,6 +581,39 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
}
};
const handleUnmoderatePost = async (
cellId: string,
postId: string,
reason: string | undefined,
cellOwner: string
) => {
toast({
title: 'Unmoderating Post',
description: 'Sending unmoderation message to the network...',
});
const result = await forumActions.unmoderatePost(
{ cellId, postId, reason, currentUser, isAuthenticated, cellOwner },
updateStateFromCache
);
if (result.success) {
toast({
title: 'Post Unmoderated',
description: 'The post is now visible again.',
});
return result.data || false;
} else {
toast({
title: 'Unmoderation Failed',
description:
result.error || 'Failed to unmoderate post. Please try again.',
variant: 'destructive',
});
return false;
}
};
const handleModerateComment = async (
cellId: string,
commentId: string,
@ -596,6 +647,39 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
}
};
const handleUnmoderateComment = async (
cellId: string,
commentId: string,
reason: string | undefined,
cellOwner: string
) => {
toast({
title: 'Unmoderating Comment',
description: 'Sending unmoderation message to the network...',
});
const result = await forumActions.unmoderateComment(
{ cellId, commentId, reason, currentUser, isAuthenticated, cellOwner },
updateStateFromCache
);
if (result.success) {
toast({
title: 'Comment Unmoderated',
description: 'The comment is now visible again.',
});
return result.data || false;
} else {
toast({
title: 'Unmoderation Failed',
description:
result.error || 'Failed to unmoderate comment. Please try again.',
variant: 'destructive',
});
return false;
}
};
const handleModerateUser = async (
cellId: string,
userAddress: string,
@ -624,6 +708,34 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
}
};
const handleUnmoderateUser = async (
cellId: string,
userAddress: string,
reason: string | undefined,
cellOwner: string
) => {
const result = await forumActions.unmoderateUser(
{ cellId, userAddress, reason, currentUser, isAuthenticated, cellOwner },
updateStateFromCache
);
if (result.success) {
toast({
title: 'User Unmoderated',
description: `User ${userAddress} has been unmoderated in this cell.`,
});
return result.data || false;
} else {
toast({
title: 'Unmoderation Failed',
description:
result.error || 'Failed to unmoderate user. Please try again.',
variant: 'destructive',
});
return false;
}
};
return (
<ForumContext.Provider
value={{
@ -652,8 +764,11 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
createCell: handleCreateCell,
refreshData: handleRefreshData,
moderatePost: handleModeratePost,
unmoderatePost: handleUnmoderatePost,
moderateComment: handleModerateComment,
unmoderateComment: handleUnmoderateComment,
moderateUser: handleModerateUser,
unmoderateUser: handleUnmoderateUser,
}}
>
{children}

View File

@ -33,6 +33,11 @@ export interface ForumActions extends ForumActionStates {
postId: string,
reason?: string
) => Promise<boolean>;
unmoderatePost: (
cellId: string,
postId: string,
reason?: string
) => Promise<boolean>;
// Comment actions
createComment: (postId: string, content: string) => Promise<Comment | null>;
@ -42,6 +47,11 @@ export interface ForumActions extends ForumActionStates {
commentId: string,
reason?: string
) => Promise<boolean>;
unmoderateComment: (
cellId: string,
commentId: string,
reason?: string
) => Promise<boolean>;
// User moderation
moderateUser: (
@ -49,6 +59,11 @@ export interface ForumActions extends ForumActionStates {
userAddress: string,
reason?: string
) => Promise<boolean>;
unmoderateUser: (
cellId: string,
userAddress: string,
reason?: string
) => Promise<boolean>;
// Data refresh
refreshData: () => Promise<void>;
@ -65,8 +80,11 @@ export function useForumActions(): ForumActions {
votePost: baseVotePost,
voteComment: baseVoteComment,
moderatePost: baseModeratePost,
unmoderatePost: baseUnmoderatePost,
moderateComment: baseModerateComment,
unmoderateComment: baseUnmoderateComment,
moderateUser: baseModerateUser,
unmoderateUser: baseUnmoderateUser,
refreshData: baseRefreshData,
isPostingCell,
isPostingPost,
@ -328,6 +346,54 @@ export function useForumActions(): ForumActions {
[permissions, currentUser, getCellById, baseModeratePost, toast]
);
// Post unmoderation
const unmoderatePost = useCallback(
async (
cellId: string,
postId: string,
reason?: string
): Promise<boolean> => {
const cell = getCellById(cellId);
const canModerate =
permissions.canModerate(cellId) &&
cell &&
currentUser?.address === cell.author;
if (!canModerate) {
toast({
title: 'Permission Denied',
description: 'You must be the cell owner to unmoderate content.',
variant: 'destructive',
});
return false;
}
try {
const result = await baseUnmoderatePost(
cellId,
postId,
reason,
cell.author
);
if (result) {
toast({
title: 'Post Unmoderated',
description: 'The post is now visible again.',
});
}
return result;
} catch {
toast({
title: 'Unmoderation Failed',
description: 'Failed to unmoderate post. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions, currentUser, getCellById, baseUnmoderatePost, toast]
);
// Comment moderation
const moderateComment = useCallback(
async (
@ -376,6 +442,54 @@ export function useForumActions(): ForumActions {
[permissions, currentUser, getCellById, baseModerateComment, toast]
);
// Comment unmoderation
const unmoderateComment = useCallback(
async (
cellId: string,
commentId: string,
reason?: string
): Promise<boolean> => {
const cell = getCellById(cellId);
const canModerate =
permissions.canModerate(cellId) &&
cell &&
currentUser?.address === cell.author;
if (!canModerate) {
toast({
title: 'Permission Denied',
description: 'You must be the cell owner to unmoderate content.',
variant: 'destructive',
});
return false;
}
try {
const result = await baseUnmoderateComment(
cellId,
commentId,
reason,
cell.author
);
if (result) {
toast({
title: 'Comment Unmoderated',
description: 'The comment is now visible again.',
});
}
return result;
} catch {
toast({
title: 'Unmoderation Failed',
description: 'Failed to unmoderate comment. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions, currentUser, getCellById, baseUnmoderateComment, toast]
);
// User moderation
const moderateUser = useCallback(
async (
@ -433,6 +547,63 @@ export function useForumActions(): ForumActions {
[permissions, currentUser, getCellById, baseModerateUser, toast]
);
// User unmoderation
const unmoderateUser = useCallback(
async (
cellId: string,
userAddress: string,
reason?: string
): Promise<boolean> => {
const cell = getCellById(cellId);
const canModerate =
permissions.canModerate(cellId) &&
cell &&
currentUser?.address === cell.author;
if (!canModerate) {
toast({
title: 'Permission Denied',
description: 'You must be the cell owner to unmoderate users.',
variant: 'destructive',
});
return false;
}
if (userAddress === currentUser?.address) {
toast({
title: 'Invalid Action',
description: 'You cannot unmoderate yourself.',
variant: 'destructive',
});
return false;
}
try {
const result = await baseUnmoderateUser(
cellId,
userAddress,
reason,
cell.author
);
if (result) {
toast({
title: 'User Unmoderated',
description: 'The user is now unmoderated in this cell.',
});
}
return result;
} catch {
toast({
title: 'Unmoderation Failed',
description: 'Failed to unmoderate user. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions, currentUser, getCellById, baseUnmoderateUser, toast]
);
// Data refresh
const refreshData = useCallback(async (): Promise<void> => {
try {
@ -465,8 +636,11 @@ export function useForumActions(): ForumActions {
votePost,
voteComment,
moderatePost,
unmoderatePost,
moderateComment,
unmoderateComment,
moderateUser,
unmoderateUser,
refreshData,
};
}

View File

@ -9,6 +9,7 @@ import {
CellMessage,
CommentMessage,
PostMessage,
EModerationAction,
} from '@/types/waku';
import { Cell, Comment, Post } from '@/types/forum';
import { EVerificationStatus, User } from '@/types/identity';
@ -380,6 +381,7 @@ export class ForumActions {
targetType: 'post',
targetId: postId,
reason,
action: EModerationAction.MODERATE,
timestamp: Date.now(),
author: currentUser!.address,
};
@ -453,6 +455,7 @@ export class ForumActions {
targetType: 'comment',
targetId: commentId,
reason,
action: EModerationAction.MODERATE,
timestamp: Date.now(),
author: currentUser!.address,
};
@ -526,6 +529,7 @@ export class ForumActions {
targetType: 'user',
targetId: userAddress,
reason,
action: EModerationAction.MODERATE,
author: currentUser!.address,
timestamp: Date.now(),
};
@ -563,6 +567,222 @@ export class ForumActions {
};
}
}
async unmoderatePost(
params: PostModerationParams,
updateStateFromCache: () => void
): Promise<ActionResult<boolean>> {
const { cellId, postId, reason, currentUser, isAuthenticated, cellOwner } =
params;
if (!isAuthenticated || !currentUser) {
return {
success: false,
error:
'Authentication required. You need to verify Ordinal ownership to unmoderate posts.',
};
}
if (currentUser.address !== cellOwner) {
return {
success: false,
error: 'Not authorized. Only the cell admin can unmoderate posts.',
};
}
try {
const unsignedMod: UnsignedModerateMessage = {
type: MessageType.MODERATE,
id: uuidv4(),
cellId,
targetType: 'post',
targetId: postId,
reason,
action: EModerationAction.UNMODERATE,
timestamp: Date.now(),
author: currentUser!.address,
};
const signed = await this.delegationManager.signMessage(unsignedMod);
if (!signed) {
const status = await this.delegationManager.getStatus(
currentUser!.address,
currentUser!.walletType
);
return {
success: false,
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
return { success: true, data: true };
} catch (error) {
console.error('Error unmoderating post:', error);
return {
success: false,
error: 'Failed to unmoderate post. Please try again.',
};
}
}
async unmoderateComment(
params: CommentModerationParams,
updateStateFromCache: () => void
): Promise<ActionResult<boolean>> {
const {
cellId,
commentId,
reason,
currentUser,
isAuthenticated,
cellOwner,
} = params;
if (!isAuthenticated || !currentUser) {
return {
success: false,
error:
'Authentication required. You need to verify Ordinal ownership to unmoderate comments.',
};
}
if (currentUser.address !== cellOwner) {
return {
success: false,
error: 'Not authorized. Only the cell admin can unmoderate comments.',
};
}
try {
const unsignedMod: UnsignedModerateMessage = {
type: MessageType.MODERATE,
id: uuidv4(),
cellId,
targetType: 'comment',
targetId: commentId,
reason,
action: EModerationAction.UNMODERATE,
timestamp: Date.now(),
author: currentUser!.address,
};
const signed = await this.delegationManager.signMessage(unsignedMod);
if (!signed) {
const status = await this.delegationManager.getStatus(
currentUser!.address,
currentUser!.walletType
);
return {
success: false,
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
return { success: true, data: true };
} catch (error) {
console.error('Error unmoderating comment:', error);
return {
success: false,
error: 'Failed to unmoderate comment. Please try again.',
};
}
}
async unmoderateUser(
params: UserModerationParams,
updateStateFromCache: () => void
): Promise<ActionResult<boolean>> {
const {
cellId,
userAddress,
reason,
currentUser,
isAuthenticated,
cellOwner,
} = params;
if (!isAuthenticated || !currentUser) {
return {
success: false,
error:
'Authentication required. You need to verify Ordinal ownership to unmoderate users.',
};
}
if (currentUser.address !== cellOwner) {
return {
success: false,
error: 'Not authorized. Only the cell admin can unmoderate users.',
};
}
try {
const unsignedMod: UnsignedModerateMessage = {
type: MessageType.MODERATE,
id: uuidv4(),
cellId,
targetType: 'user',
targetId: userAddress,
reason,
action: EModerationAction.UNMODERATE,
author: currentUser!.address,
timestamp: Date.now(),
};
const signed = await this.delegationManager.signMessage(unsignedMod);
if (!signed) {
const status = await this.delegationManager.getStatus(
currentUser!.address,
currentUser!.walletType
);
return {
success: false,
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
return { success: true, data: true };
} catch (error) {
console.error('Error unmoderating user:', error);
return {
success: false,
error: 'Failed to unmoderate user. Please try again.',
};
}
}
}
// Base interface for all actions that require user authentication

View File

@ -4,6 +4,7 @@ import {
CommentMessage,
PostMessage,
VoteMessage,
EModerationAction,
} from '@/types/waku';
import messageManager from '@/lib/waku';
import { RelevanceCalculator } from './RelevanceCalculator';
@ -79,7 +80,10 @@ export const transformPost = async (
);
const modMsg = messageManager.messageCache.moderations[postMessage.id];
const isPostModerated = !!modMsg && modMsg.targetType === 'post';
const isPostModerated =
!!modMsg &&
modMsg.targetType === 'post' &&
modMsg.action === EModerationAction.MODERATE;
const userModMsg = Object.values(
messageManager.messageCache.moderations
).find(
@ -88,7 +92,8 @@ export const transformPost = async (
m.cellId === postMessage.cellId &&
m.targetId === postMessage.author
);
const isUserModerated = !!userModMsg;
const isUserModerated =
!!userModMsg && userModMsg.action === EModerationAction.MODERATE;
const transformedPost: Post = {
id: postMessage.id,
@ -194,7 +199,10 @@ export const transformComment = async (
);
const modMsg = messageManager.messageCache.moderations[commentMessage.id];
const isCommentModerated = !!modMsg && modMsg.targetType === 'comment';
const isCommentModerated =
!!modMsg &&
modMsg.targetType === 'comment' &&
modMsg.action === EModerationAction.MODERATE;
// Find the post to get the correct cell ID
const parentPost = Object.values(messageManager.messageCache.posts).find(
post => post.id === commentMessage.postId
@ -207,7 +215,8 @@ export const transformComment = async (
m.cellId === parentPost?.cellId &&
m.targetId === commentMessage.author
);
const isUserModerated = !!userModMsg;
const isUserModerated =
!!userModMsg && userModMsg.action === EModerationAction.MODERATE;
const transformedComment: Comment = {
id: commentMessage.id,

View File

@ -13,6 +13,14 @@ export enum MessageType {
USER_PROFILE_UPDATE = 'user_profile_update',
}
/**
* Moderation action types
*/
export enum EModerationAction {
MODERATE = 'moderate',
UNMODERATE = 'unmoderate',
}
/**
* Base interface for unsigned messages (before signing)
*/
@ -67,6 +75,7 @@ export interface UnsignedModerateMessage extends UnsignedBaseMessage {
targetType: 'post' | 'comment' | 'user';
targetId: string; // postId, commentId, or user address (for user moderation)
reason?: string;
action: EModerationAction;
}
export interface UnsignedUserProfileUpdateMessage extends UnsignedBaseMessage {
@ -110,6 +119,7 @@ export interface ModerateMessage extends BaseMessage {
targetType: 'post' | 'comment' | 'user';
targetId: string; // postId, commentId, or user address (for user moderation)
reason?: string;
action: EModerationAction;
}
export interface UserProfileUpdateMessage extends BaseMessage {

View File

@ -3,10 +3,7 @@ import tailwindcssAnimate from 'tailwindcss-animate';
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
],
content: ['./index.html', './src/**/*.{ts,tsx}'],
prefix: '',
theme: {
container: {