diff --git a/src/components/CommentCard.tsx b/src/components/CommentCard.tsx index e6db1ae..09e6ea0 100644 --- a/src/components/CommentCard.tsx +++ b/src/components/CommentCard.tsx @@ -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 = ({ cellId, canModerate, onModerateComment, + onUnmoderateComment, onModerateUser, }) => { const { voteComment, isVoting } = useForumActions(); @@ -155,6 +157,23 @@ const CommentCard: React.FC = ({ )} + {canModerate && comment.moderated && ( + + + + + +

Unmoderate comment

+
+
+ )} {cellId && canModerate && ( diff --git a/src/components/PostDetail.tsx b/src/components/PostDetail.tsx index ab7350c..28a591f 100644 --- a/src/components/PostDetail.tsx +++ b/src/components/PostDetail.tsx @@ -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} /> )) diff --git a/src/components/PostList.tsx b/src/components/PostList.tsx index 3ced109..e725c20 100644 --- a/src/components/PostList.tsx +++ b/src/components/PostList.tsx @@ -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 = () => { )} - {post.moderated && ( - - [Moderated] - + {canModerate(cell.id) && post.moderated && ( + + + + + +

Unmoderate post

+
+
)} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 552591a..cdee2e7 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -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; diff --git a/src/contexts/ForumContext.tsx b/src/contexts/ForumContext.tsx index 9095d61..e63e743 100644 --- a/src/contexts/ForumContext.tsx +++ b/src/contexts/ForumContext.tsx @@ -70,18 +70,36 @@ interface ForumContextType { reason: string | undefined, cellOwner: string ) => Promise; + unmoderatePost: ( + cellId: string, + postId: string, + reason: string | undefined, + cellOwner: string + ) => Promise; moderateComment: ( cellId: string, commentId: string, reason: string | undefined, cellOwner: string ) => Promise; + unmoderateComment: ( + cellId: string, + commentId: string, + reason: string | undefined, + cellOwner: string + ) => Promise; moderateUser: ( cellId: string, userAddress: string, reason: string | undefined, cellOwner: string ) => Promise; + unmoderateUser: ( + cellId: string, + userAddress: string, + reason: string | undefined, + cellOwner: string + ) => Promise; } const ForumContext = createContext(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 ( {children} diff --git a/src/hooks/actions/useForumActions.ts b/src/hooks/actions/useForumActions.ts index 0139d60..96402a0 100644 --- a/src/hooks/actions/useForumActions.ts +++ b/src/hooks/actions/useForumActions.ts @@ -33,6 +33,11 @@ export interface ForumActions extends ForumActionStates { postId: string, reason?: string ) => Promise; + unmoderatePost: ( + cellId: string, + postId: string, + reason?: string + ) => Promise; // Comment actions createComment: (postId: string, content: string) => Promise; @@ -42,6 +47,11 @@ export interface ForumActions extends ForumActionStates { commentId: string, reason?: string ) => Promise; + unmoderateComment: ( + cellId: string, + commentId: string, + reason?: string + ) => Promise; // User moderation moderateUser: ( @@ -49,6 +59,11 @@ export interface ForumActions extends ForumActionStates { userAddress: string, reason?: string ) => Promise; + unmoderateUser: ( + cellId: string, + userAddress: string, + reason?: string + ) => Promise; // Data refresh refreshData: () => Promise; @@ -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 => { + 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 => { + 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 => { + 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 => { try { @@ -465,8 +636,11 @@ export function useForumActions(): ForumActions { votePost, voteComment, moderatePost, + unmoderatePost, moderateComment, + unmoderateComment, moderateUser, + unmoderateUser, refreshData, }; } diff --git a/src/lib/forum/ForumActions.ts b/src/lib/forum/ForumActions.ts index dc77ed9..978f069 100644 --- a/src/lib/forum/ForumActions.ts +++ b/src/lib/forum/ForumActions.ts @@ -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> { + 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> { + 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> { + 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 diff --git a/src/lib/forum/transformers.ts b/src/lib/forum/transformers.ts index e4b5bb9..4fb64f3 100644 --- a/src/lib/forum/transformers.ts +++ b/src/lib/forum/transformers.ts @@ -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, diff --git a/src/types/waku.ts b/src/types/waku.ts index 2533ace..ac1e7c9 100644 --- a/src/types/waku.ts +++ b/src/types/waku.ts @@ -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 { diff --git a/tailwind.config.ts b/tailwind.config.ts index 54686b5..378f455 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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: {