Overview
The forum system supports emoji reactions on posts, allowing users to express their feelings and engagement without writing a full reply. The system also includes topic upvoting for content curation.
Emoji Reactions
Default Emoji Set
The system provides these default emojis:
π π₯ π€ π π β€οΈ π π π€
Using the EmojiReactions Component
src/components/Forum/EmojiReactions.tsx
import EmojiReactions from '@/components/Forum/EmojiReactions' ;
function PostCard ({ post }) {
return (
< div >
< p >{post. content } </ p >
< EmojiReactions
targetType = "post"
targetId = {post. id }
initialByEmoji = {post. reactionsByEmoji }
/>
</ div >
);
}
Component Features
The component uses optimistic UI updates with server sync: // Optimistically update UI
setByEmoji (( prevState ) => {
if ( currentlyMine ) {
// Remove reaction
const arr = prevState [ emoji ]. filter ( a => a . toLowerCase () !== me );
return arr . length === 0
? { ... prevState , [emoji]: undefined }
: { ... prevState , [emoji]: arr };
} else {
// Add reaction
const arr = [ ... ( prevState [ emoji ] || []), address ];
return { ... prevState , [emoji]: arr };
}
});
// Make API call
const ok = await ( currentlyMine ? removeReaction : addReaction )(
targetType ,
targetId ,
emoji
);
// Revert on failure
if ( ! ok ) setByEmoji ( previousState );
Emojis are normalized to NFC form for consistent storage: function normalizeEmoji ( input : string ) : string {
// Trim and normalize to NFC
const e = ( input || '' ). trim (). normalize ( 'NFC' );
// Use Intl.Segmenter to ensure single grapheme cluster
const seg = new Intl . Segmenter ( 'en' , { granularity: 'grapheme' });
const it = seg . segment ( e )[ Symbol . iterator ]();
return e ;
}
This handles complex emojis with skin tones and ZWJ sequences.
The system tracks which users reacted with which emojis: interface ReactionsByEmoji {
'π' : [ '0x123...' , '0x456...' ],
'π₯' : [ '0x789...' ],
// ... more reactions
}
Server Actions
Add Reaction
src/lib/actions/forum/reactions.ts
export async function addForumReaction (
data : z . infer < typeof addReactionSchema >
) {
const validated = addReactionSchema . parse ( data );
const { slug } = Tenant . current ();
// Verify signature
const isValid = await verifyMessage ({
address: validated . address as `0x ${ string } ` ,
message: validated . message ,
signature: validated . signature as `0x ${ string } ` ,
});
if ( ! isValid ) return { success: false , error: 'Invalid signature' };
// Check voting power (unless user has RBAC permission)
const hasPostPermission = await checkPermission (
validated . address ,
slug as DaoSlug ,
'forums' ,
'posts' ,
'create'
);
if ( ! hasPostPermission ) {
const votingPowerBigInt = await fetchVotingPowerFromContract (
client ,
validated . address ,
{ namespace: tenant . namespace , contracts: tenant . contracts }
);
const currentVP = formatVotingPower ( votingPowerBigInt );
const vpCheck = await canPerformAction ( currentVP , slug );
if ( ! vpCheck . allowed ) {
return {
success: false ,
error: formatVPError ( vpCheck , 'react to posts' ),
};
}
}
// Normalize emoji
const emoji = normalizeEmoji ( validated . emoji );
// Upsert reaction (idempotent)
const created = await prismaWeb2Client . forumPostReaction . upsert ({
where: {
dao_slug_address_postId_emoji: {
dao_slug: slug ,
address: validated . address . toLowerCase (),
postId: validated . targetId ,
emoji ,
},
},
update: {},
create: {
dao_slug: slug ,
address: validated . address . toLowerCase (),
postId: validated . targetId ,
emoji ,
},
});
return { success: true , data: created };
}
Remove Reaction
src/lib/actions/forum/reactions.ts
export async function removeForumReaction (
data : z . infer < typeof removeReactionSchema >
) {
const validated = removeReactionSchema . parse ( data );
const { slug } = Tenant . current ();
// Verify signature
const isValid = await verifyMessage ({
address: validated . address as `0x ${ string } ` ,
message: validated . message ,
signature: validated . signature as `0x ${ string } ` ,
});
if ( ! isValid ) return { success: false , error: 'Invalid signature' };
// Normalize emoji
const emoji = normalizeEmoji ( validated . emoji );
// Delete reaction
await prismaWeb2Client . forumPostReaction . delete ({
where: {
dao_slug_address_postId_emoji: {
dao_slug: slug ,
address: validated . address . toLowerCase (),
postId: validated . targetId ,
emoji ,
},
},
});
return { success: true };
}
Topic Upvoting
Topics can be upvoted to show support and help surface quality content.
Implementation
Upvotes are stored as votes on the first post of a topic:
model forum_post_votes {
dao_slug DaoSlug
address String
post_id Int
vote Int // 1 for upvote, -1 for downvote
created_at DateTime @ default ( now ())
@@ id ([ dao_slug , address , post_id ])
}
Upvote a Topic
src/lib/actions/forum/topics.ts
export async function upvoteForumTopic ( data ) {
// Verify signature
const isValid = await verifyMessage ({
address: validatedData . address ,
message: validatedData . message ,
signature: validatedData . signature ,
});
// Upsert vote (one vote per user per topic)
await prismaWeb2Client . forumPostVote . upsert ({
where: {
dao_slug_address_postId: {
dao_slug: slug ,
address: validatedData . address . toLowerCase (),
postId: validatedData . postId ,
},
},
update: { vote: 1 },
create: {
dao_slug: slug ,
address: validatedData . address . toLowerCase (),
postId: validatedData . postId ,
vote: 1 ,
},
});
return { success: true };
}
Remove Upvote
export async function removeUpvoteForumTopic ( data ) {
await prismaWeb2Client . forumPostVote . delete ({
where: {
dao_slug_address_postId: {
dao_slug: slug ,
address: validatedData . address . toLowerCase (),
postId: validatedData . postId ,
},
},
});
return { success: true };
}
Get Upvote Count
export async function getForumTopicUpvotes ( topicId : number ) {
const topic = await prismaWeb2Client . forumTopic . findUnique ({
where: { id: topicId },
include: {
posts: {
take: 1 ,
orderBy: { createdAt: 'asc' },
include: {
_count: {
select: {
votes: { where: { vote: 1 } },
},
},
},
},
},
});
const upvotes = topic ?. posts [ 0 ]?. _count ?. votes || 0 ;
return { success: true , data: upvotes };
}
React Hook Integration
export function useForum () {
const addReaction = async (
targetType : 'post' | 'topic' ,
targetId : number ,
emoji : string
) => {
const address = await requireLogin ();
if ( ! address ) return false ;
const { signature , message } = await signMessage (
`React with ${ emoji } to ${ targetType } ${ targetId } `
);
const result = await addForumReaction ({
targetType ,
targetId ,
emoji ,
address ,
signature ,
message ,
});
return result . success ;
};
const removeReaction = async (
targetType : 'post' | 'topic' ,
targetId : number ,
emoji : string
) => {
const address = await requireLogin ();
if ( ! address ) return false ;
const { signature , message } = await signMessage (
`Remove ${ emoji } reaction from ${ targetType } ${ targetId } `
);
const result = await removeForumReaction ({
targetType ,
targetId ,
emoji ,
address ,
signature ,
message ,
});
return result . success ;
};
return { addReaction , removeReaction };
}
Voting Power Requirements
Users need sufficient voting power to add reactions unless they have the forums.posts.create RBAC permission.
// Check voting power before allowing reaction
const hasPostPermission = await checkPermission (
address ,
daoSlug ,
'forums' ,
'posts' ,
'create'
);
if ( ! hasPostPermission ) {
const currentVP = await fetchVotingPower ( address );
const vpCheck = await canPerformAction ( currentVP , daoSlug );
if ( ! vpCheck . allowed ) {
// Show insufficient VP modal
return { success: false , error: 'Insufficient voting power' };
}
}
Insufficient VP Modal
Display a helpful modal when users lack voting power:
src/components/Forum/InsufficientVPModal.tsx
import { InsufficientVPModal } from '@/components/Forum/InsufficientVPModal' ;
function EmojiReactions () {
const [ showVPModal , setShowVPModal ] = useState ( false );
const handleReaction = async ( emoji : string ) => {
if ( ! permissions . canReact ) {
setShowVPModal ( true );
return ;
}
// Add reaction
};
return (
<>
{ /* Emoji buttons */ }
< InsufficientVPModal
isOpen = { showVPModal }
onClose = {() => setShowVPModal ( false )}
action = "react"
/>
</>
);
}
Database Schema
CREATE TABLE forum_post_reactions (
dao_slug VARCHAR NOT NULL ,
address VARCHAR NOT NULL ,
post_id INTEGER NOT NULL ,
emoji VARCHAR ( 16 ) NOT NULL ,
created_at TIMESTAMP DEFAULT NOW (),
PRIMARY KEY (dao_slug, address , post_id, emoji),
FOREIGN KEY (post_id) REFERENCES forum_posts(id) ON DELETE CASCADE
);
CREATE TABLE forum_post_votes (
dao_slug VARCHAR NOT NULL ,
address VARCHAR NOT NULL ,
post_id INTEGER NOT NULL ,
vote SMALLINT NOT NULL ,
created_at TIMESTAMP DEFAULT NOW (),
PRIMARY KEY (dao_slug, address , post_id),
FOREIGN KEY (post_id) REFERENCES forum_posts(id) ON DELETE CASCADE
);
Best Practices
Always normalize emoji to NFC form to ensure consistent storage and matching.
Update UI immediately and revert on error for better UX.
Make operations idempotent
Use upsert for adding reactions so duplicate requests donβt cause errors.
Always verify wallet signatures before modifying reaction data.
Verify users have sufficient VP before allowing reactions (unless they have RBAC permissions).
Next Steps
Topics & Posts Learn about creating topics and posts
Moderation Set up content moderation and admin tools