diff --git a/README.md b/README.md index e7c9caa..9f61ed4 100644 --- a/README.md +++ b/README.md @@ -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) \ No newline at end of file +- [ ] store message cache in indexedDB -- make app local-first (update from/to Waku when available) +- [ ] moderation + - [ ] admins can "moderate" comments/posts \ No newline at end of file diff --git a/src/components/PostDetail.tsx b/src/components/PostDetail.tsx index 5b10591..061de25 100644 --- a/src/components/PostDetail.tsx +++ b/src/components/PostDetail.tsx @@ -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 (
@@ -199,7 +210,7 @@ const PostDetail = () => {

No comments yet

) : ( - postComments.map(comment => ( + visibleComments.map(comment => (
@@ -238,6 +249,14 @@ const PostDetail = () => {

{comment.content}

+ {isCellAdmin && !comment.moderated && ( + + )} + {comment.moderated && ( + [Moderated] + )}
diff --git a/src/components/PostList.tsx b/src/components/PostList.tsx index 98f0143..ecb256a 100644 --- a/src/components/PostList.tsx +++ b/src/components/PostList.tsx @@ -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 (
@@ -209,7 +222,7 @@ const PostList = () => {

) : ( - cellPosts.map(post => ( + visiblePosts.map(post => (
@@ -241,6 +254,14 @@ const PostList = () => { by {post.authorAddress.slice(0, 6)}...{post.authorAddress.slice(-4)}
+ {isCellAdmin && !post.moderated && ( + + )} + {post.moderated && ( + [Moderated] + )}
diff --git a/src/contexts/ForumContext.tsx b/src/contexts/ForumContext.tsx index 00d2970..5f46cac 100644 --- a/src/contexts/ForumContext.tsx +++ b/src/contexts/ForumContext.tsx @@ -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; createCell: (name: string, description: string, icon: string) => Promise; refreshData: () => Promise; + moderatePost: ( + cellId: string, + postId: string, + reason: string | undefined, + cellOwner: string + ) => Promise; + moderateComment: ( + cellId: string, + commentId: string, + reason: string | undefined, + cellOwner: string + ) => Promise; } const ForumContext = createContext(undefined); diff --git a/src/contexts/forum/actions.ts b/src/contexts/forum/actions.ts index 661b164..0923b18 100644 --- a/src/contexts/forum/actions.ts +++ b/src/contexts/forum/actions.ts @@ -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( +type AllowedMessages = PostMessage | CommentMessage | VoteMessage | CellMessage | ModerateMessage; + +async function signAndSendMessage( 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 => { + 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 => { + 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; + } }; \ No newline at end of file diff --git a/src/contexts/forum/transformers.ts b/src/contexts/forum/transformers.ts index 997610c..584a69f 100644 --- a/src/contexts/forum/transformers.ts +++ b/src/contexts/forum/transformers.ts @@ -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, }; }; diff --git a/src/lib/waku/constants.ts b/src/lib/waku/constants.ts index 27e4103..e4a984f 100644 --- a/src/lib/waku/constants.ts +++ b/src/lib/waku/constants.ts @@ -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 = { diff --git a/src/lib/waku/index.ts b/src/lib/waku/index.ts index a341dde..0ac654f 100644 --- a/src/lib/waku/index.ts +++ b/src/lib/waku/index.ts @@ -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 { @@ -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"); diff --git a/src/lib/waku/lightpush_filter.ts b/src/lib/waku/lightpush_filter.ts index 1e5a139..8cf09b3 100644 --- a/src/lib/waku/lightpush_filter.ts +++ b/src/lib/waku/lightpush_filter.ts @@ -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; diff --git a/src/lib/waku/types.ts b/src/lib/waku/types.ts index 8d2c153..1d6e49f 100644 --- a/src/lib/waku/types.ts +++ b/src/lib/waku/types.ts @@ -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 */ diff --git a/src/types/index.ts b/src/types/index.ts index c1bb9a9..e3c2229 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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