mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 21:03:09 +00:00
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:
parent
0eff3031ad
commit
a4fc3c1aaf
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user