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 => (
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
|