feat(moderation): cell admin can mark posts and comments as moderated - Adds ModerateMessage type and Waku support - Cell admin can moderate posts/comments, which are then hidden for others - Moderation is local, decentralized, and reversible - UI: admin-only 'Moderate' button, [Moderated] label, filtering - Fully coherent with OpChan PoC proposal moderation requirements

This commit is contained in:
Danish Arora 2025-06-06 16:42:00 +05:30
parent 0eff3031ad
commit a4fc3c1aaf
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
11 changed files with 252 additions and 28 deletions

View File

@ -3,4 +3,6 @@
- supports Phantom
- [x] replace mock Ordinal verification (API)
- [ ] figure out using actual icons for cells
- [ ] store message cache in indexedDB -- make app local-first (update from/to Waku when available)
- [ ] store message cache in indexedDB -- make app local-first (update from/to Waku when available)
- [ ] moderation
- [ ] admins can "moderate" comments/posts

View File

@ -25,7 +25,7 @@ const PostDetail = () => {
isPostingComment,
isVoting,
isRefreshing,
refreshData
moderateComment
} = useForum();
const { currentUser, isAuthenticated, verificationStatus } = useAuth();
const [newComment, setNewComment] = useState('');
@ -58,6 +58,11 @@ const PostDetail = () => {
const cell = getCellById(post.cellId);
const postComments = getCommentsByPost(post.id);
const isCellAdmin = currentUser && cell && currentUser.address === cell.signature;
const visibleComments = isCellAdmin
? postComments
: postComments.filter(comment => !comment.moderated);
const handleCreateComment = async (e: React.FormEvent) => {
e.preventDefault();
@ -96,6 +101,12 @@ const PostDetail = () => {
return `https://api.dicebear.com/7.x/identicon/svg?seed=${address}`;
};
const handleModerateComment = async (commentId: string) => {
const reason = window.prompt('Enter a reason for moderation (optional):') || undefined;
if (!cell) return;
await moderateComment(cell.id, commentId, reason, cell.signature);
};
return (
<div className="container mx-auto px-4 py-6">
<div className="mb-6">
@ -199,7 +210,7 @@ const PostDetail = () => {
<p>No comments yet</p>
</div>
) : (
postComments.map(comment => (
visibleComments.map(comment => (
<div key={comment.id} className="comment-card" id={`comment-${comment.id}`}>
<div className="flex gap-2 items-start">
<div className="flex flex-col items-center w-5 pt-0.5">
@ -238,6 +249,14 @@ const PostDetail = () => {
</span>
</div>
<p className="text-sm break-words">{comment.content}</p>
{isCellAdmin && !comment.moderated && (
<Button size="sm" variant="destructive" className="ml-2" onClick={() => handleModerateComment(comment.id)}>
Moderate
</Button>
)}
{comment.moderated && (
<span className="ml-2 text-xs text-red-500">[Moderated]</span>
)}
</div>
</div>
</div>

View File

@ -24,7 +24,8 @@ const PostList = () => {
refreshData,
votePost,
isVoting,
posts
posts,
moderatePost
} = useForum();
const { isAuthenticated, currentUser, verificationStatus } = useAuth();
const [newPostTitle, setNewPostTitle] = useState('');
@ -108,6 +109,18 @@ const PostList = () => {
return votes.some(vote => vote.author === currentUser.address);
};
// Only show unmoderated posts, or all if admin
const isCellAdmin = currentUser && cell && currentUser.address === cell.signature;
const visiblePosts = isCellAdmin
? cellPosts
: cellPosts.filter(post => !post.moderated);
const handleModerate = async (postId: string) => {
const reason = window.prompt('Enter a reason for moderation (optional):') || undefined;
if (!cell) return;
await moderatePost(cell.id, postId, reason, cell.signature);
};
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6">
@ -209,7 +222,7 @@ const PostList = () => {
</p>
</div>
) : (
cellPosts.map(post => (
visiblePosts.map(post => (
<div key={post.id} className="post-card p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 hover:bg-cyber-muted/30 transition duration-200">
<div className="flex gap-4">
<div className="flex flex-col items-center">
@ -241,6 +254,14 @@ const PostList = () => {
<span>by {post.authorAddress.slice(0, 6)}...{post.authorAddress.slice(-4)}</span>
</div>
</Link>
{isCellAdmin && !post.moderated && (
<Button size="xs" variant="destructive" className="ml-2" onClick={() => handleModerate(post.id)}>
Moderate
</Button>
)}
{post.moderated && (
<span className="ml-2 text-xs text-red-500">[Moderated]</span>
)}
</div>
</div>
</div>

View File

@ -6,7 +6,9 @@ import {
createPost,
createComment,
vote,
createCell
createCell,
moderatePost,
moderateComment
} from './forum/actions';
import {
setupPeriodicQueries,
@ -39,6 +41,18 @@ interface ForumContextType {
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
createCell: (name: string, description: string, icon: string) => Promise<Cell | null>;
refreshData: () => Promise<void>;
moderatePost: (
cellId: string,
postId: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
moderateComment: (
cellId: string,
commentId: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
}
const ForumContext = createContext<ForumContextType | undefined>(undefined);

View File

@ -1,5 +1,5 @@
import { v4 as uuidv4 } from 'uuid';
import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } from '@/lib/waku/types';
import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage, ModerateMessage } from '@/lib/waku/types';
import messageManager from '@/lib/waku';
import { Cell, Comment, Post, User } from '@/types';
import { transformCell, transformComment, transformPost } from './transformers';
@ -11,7 +11,9 @@ type ToastFunction = (props: {
variant?: "default" | "destructive";
}) => void;
async function signAndSendMessage<T extends PostMessage | CommentMessage | VoteMessage | CellMessage>(
type AllowedMessages = PostMessage | CommentMessage | VoteMessage | CellMessage | ModerateMessage;
async function signAndSendMessage<T extends AllowedMessages>(
message: T,
currentUser: User | null,
messageSigning: MessageSigning,
@ -336,4 +338,134 @@ export const vote = async (
});
return false;
}
};
export const moderatePost = async (
cellId: string,
postId: string,
reason: string | undefined,
currentUser: User | null,
isAuthenticated: boolean,
cellOwner: string,
toast: ToastFunction,
updateStateFromCache: () => void,
messageSigning?: MessageSigning
): Promise<boolean> => {
if (!isAuthenticated || !currentUser) {
toast({
title: "Authentication Required",
description: "You need to verify Ordinal ownership to moderate posts.",
variant: "destructive",
});
return false;
}
if (currentUser.address !== cellOwner) {
toast({
title: "Not Authorized",
description: "Only the cell admin can moderate posts.",
variant: "destructive",
});
return false;
}
try {
toast({
title: "Moderating Post",
description: "Sending moderation message to the network...",
});
const modMsg: ModerateMessage = {
type: MessageType.MODERATE,
cellId,
targetType: 'post',
targetId: postId,
reason,
timestamp: Date.now(),
author: currentUser.address,
};
const sentMessage = await signAndSendMessage(
modMsg,
currentUser,
messageSigning!,
toast
);
if (!sentMessage) return false;
updateStateFromCache();
toast({
title: "Post Moderated",
description: "The post has been marked as moderated.",
});
return true;
} catch (error) {
console.error("Error moderating post:", error);
toast({
title: "Moderation Failed",
description: "Failed to moderate post. Please try again.",
variant: "destructive",
});
return false;
}
};
export const moderateComment = async (
cellId: string,
commentId: string,
reason: string | undefined,
currentUser: User | null,
isAuthenticated: boolean,
cellOwner: string,
toast: ToastFunction,
updateStateFromCache: () => void,
messageSigning?: MessageSigning
): Promise<boolean> => {
if (!isAuthenticated || !currentUser) {
toast({
title: "Authentication Required",
description: "You need to verify Ordinal ownership to moderate comments.",
variant: "destructive",
});
return false;
}
if (currentUser.address !== cellOwner) {
toast({
title: "Not Authorized",
description: "Only the cell admin can moderate comments.",
variant: "destructive",
});
return false;
}
try {
toast({
title: "Moderating Comment",
description: "Sending moderation message to the network...",
});
const modMsg: ModerateMessage = {
type: MessageType.MODERATE,
cellId,
targetType: 'comment',
targetId: commentId,
reason,
timestamp: Date.now(),
author: currentUser.address,
};
const sentMessage = await signAndSendMessage(
modMsg,
currentUser,
messageSigning!,
toast
);
if (!sentMessage) return false;
updateStateFromCache();
toast({
title: "Comment Moderated",
description: "The comment has been marked as moderated.",
});
return true;
} catch (error) {
console.error("Error moderating comment:", error);
toast({
title: "Moderation Failed",
description: "Failed to moderate comment. Please try again.",
variant: "destructive",
});
return false;
}
};

View File

@ -2,15 +2,12 @@ import { Cell, Post, Comment, OpchanMessage } from '@/types';
import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } from '@/lib/waku/types';
import messageManager from '@/lib/waku';
// Type for the verification function
type VerifyFunction = (message: OpchanMessage) => boolean;
// Helper function to transform CellMessage to Cell
export const transformCell = (
cellMessage: CellMessage,
verifyMessage?: VerifyFunction
): Cell | null => {
// Verify the message if a verification function is provided
if (verifyMessage && !verifyMessage(cellMessage)) {
console.warn(`Cell message ${cellMessage.id} failed verification`);
return null;
@ -21,7 +18,6 @@ export const transformCell = (
name: cellMessage.name,
description: cellMessage.description,
icon: cellMessage.icon,
// Include signature information for future verification if needed
signature: cellMessage.signature,
browserPubKey: cellMessage.browserPubKey
};
@ -51,6 +47,9 @@ export const transformPost = (
const upvotes = filteredVotes.filter(vote => vote.value === 1);
const downvotes = filteredVotes.filter(vote => vote.value === -1);
const modMsg = messageManager.messageCache.moderations[postMessage.id];
const isModerated = !!modMsg && modMsg.targetType === 'post';
return {
id: postMessage.id,
cellId: postMessage.cellId,
@ -60,9 +59,12 @@ export const transformPost = (
timestamp: postMessage.timestamp,
upvotes: upvotes,
downvotes: downvotes,
// Include signature information for future verification if needed
signature: postMessage.signature,
browserPubKey: postMessage.browserPubKey
browserPubKey: postMessage.browserPubKey,
moderated: isModerated,
moderatedBy: isModerated ? modMsg.author : undefined,
moderationReason: isModerated ? modMsg.reason : undefined,
moderationTimestamp: isModerated ? modMsg.timestamp : undefined,
};
};
@ -90,6 +92,10 @@ export const transformComment = (
const upvotes = filteredVotes.filter(vote => vote.value === 1);
const downvotes = filteredVotes.filter(vote => vote.value === -1);
// Check for moderation
const modMsg = messageManager.messageCache.moderations[commentMessage.id];
const isModerated = !!modMsg && modMsg.targetType === 'comment';
return {
id: commentMessage.id,
postId: commentMessage.postId,
@ -98,9 +104,12 @@ export const transformComment = (
timestamp: commentMessage.timestamp,
upvotes: upvotes,
downvotes: downvotes,
// Include signature information for future verification if needed
signature: commentMessage.signature,
browserPubKey: commentMessage.browserPubKey
browserPubKey: commentMessage.browserPubKey,
moderated: isModerated,
moderatedBy: isModerated ? modMsg.author : undefined,
moderationReason: isModerated ? modMsg.reason : undefined,
moderationTimestamp: isModerated ? modMsg.timestamp : undefined,
};
};

View File

@ -24,9 +24,9 @@ export const NETWORK_CONFIG: NetworkConfig = {
export const BOOTSTRAP_NODES = {
"42": [
"/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ",
// "/dns4/node-01.do-ams3.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmNaeL4p3WEYzC9mgXBmBWSgWjPHRvatZTXnp8Jgv3iKsb",
// "/dns4/vps-aaa00d52.vps.ovh.ca/tcp/8000/wss/p2p/16Uiu2HAm9PftGgHZwWE3wzdMde4m3kT2eYJFXLZfGoSED3gysofk"
]
"/dns4/node-01.do-ams3.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmNaeL4p3WEYzC9mgXBmBWSgWjPHRvatZTXnp8Jgv3iKsb",
"/dns4/vps-aaa00d52.vps.ovh.ca/tcp/8000/wss/p2p/16Uiu2HAm9PftGgHZwWE3wzdMde4m3kT2eYJFXLZfGoSED3gysofk"
],
};
export const LOCAL_STORAGE_KEYS = {

View File

@ -4,7 +4,7 @@
import { createDecoder, createLightNode, HealthStatus, HealthStatusChangeEvents, LightNode } from "@waku/sdk";
import { BOOTSTRAP_NODES } from "./constants";
import StoreManager from "./store";
import { CommentCache, MessageType, VoteCache } from "./types";
import { CommentCache, MessageType, VoteCache, ModerateMessage } from "./types";
import { PostCache } from "./types";
import { CellCache } from "./types";
import { OpchanMessage } from "@/types";
@ -28,11 +28,13 @@ class MessageManager {
posts: PostCache;
comments: CommentCache;
votes: VoteCache;
moderations: { [targetId: string]: ModerateMessage };
} = {
cells: {},
posts: {},
comments: {},
votes: {}
votes: {},
moderations: {}
}
public static async create(): Promise<MessageManager> {
@ -169,7 +171,7 @@ class MessageManager {
this.updateCache(message);
}
public async subscribeToMessages(types: MessageType[] = [MessageType.CELL, MessageType.POST, MessageType.COMMENT, MessageType.VOTE]) {
public async subscribeToMessages(types: MessageType[] = [MessageType.CELL, MessageType.POST, MessageType.COMMENT, MessageType.VOTE, MessageType.MODERATE]) {
const { result, subscription } = await this.ephemeralProtocolsManager.subscribeToMessages(types);
for (const message of result) {
@ -196,6 +198,12 @@ class MessageManager {
this.messageCache.votes[voteKey] = message;
break;
}
case MessageType.MODERATE: {
// Type guard for ModerateMessage
const modMsg = message as ModerateMessage;
this.messageCache.moderations[modMsg.targetId] = modMsg;
break;
}
default:
// TypeScript should ensure we don't reach this case with proper OpchanMessage types
console.warn("Received message with unknown type");

View File

@ -1,6 +1,5 @@
import { LightNode } from "@waku/sdk";
import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } from "./types";
import { CONTENT_TOPICS } from "./constants";
import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage, ModerateMessage } from "./types";
import { OpchanMessage } from "@/types";
import { encodeMessage, encoders, decoders, decodeMessage } from "./codec";
@ -20,7 +19,7 @@ export class EphemeralProtocolsManager {
}
public async subscribeToMessages(types: MessageType[]) {
const result: (CellMessage | PostMessage | CommentMessage | VoteMessage)[] = [];
const result: (CellMessage | PostMessage | CommentMessage | VoteMessage | ModerateMessage)[] = [];
const subscription = await this.node.filter.subscribe(Object.values(decoders), async (message) => {
const {payload} = message;

View File

@ -5,7 +5,8 @@ export enum MessageType {
CELL = 'cell',
POST = 'post',
COMMENT = 'comment',
VOTE = 'vote'
VOTE = 'vote',
MODERATE = 'moderate',
}
/**
@ -61,6 +62,17 @@ export interface VoteMessage extends BaseMessage {
value: 1 | -1;
}
/**
* Represents a moderate message
*/
export interface ModerateMessage extends BaseMessage {
type: MessageType.MODERATE;
cellId: string;
targetType: 'post' | 'comment';
targetId: string;
reason?: string;
}
/**
* Cache objects for storing messages
*/

View File

@ -1,6 +1,6 @@
import { CellMessage, CommentMessage, PostMessage, VoteMessage } from "@/lib/waku/types";
import { CellMessage, CommentMessage, PostMessage, VoteMessage, ModerateMessage } from "@/lib/waku/types";
export type OpchanMessage = CellMessage | PostMessage | CommentMessage | VoteMessage;
export type OpchanMessage = CellMessage | PostMessage | CommentMessage | VoteMessage | ModerateMessage;
export interface User {
address: string;
@ -32,6 +32,10 @@ export interface Post {
downvotes: VoteMessage[];
signature?: string; // Message signature
browserPubKey?: string; // Public key that signed the message
moderated?: boolean;
moderatedBy?: string;
moderationReason?: string;
moderationTimestamp?: number;
}
export interface Comment {
@ -44,6 +48,10 @@ export interface Comment {
downvotes: VoteMessage[];
signature?: string; // Message signature
browserPubKey?: string; // Public key that signed the message
moderated?: boolean;
moderatedBy?: string;
moderationReason?: string;
moderationTimestamp?: number;
}
// Extended message types for verification