TUNA provides a robust commenting system that stores short comments directly on-chain and uses Walrus for longer comments, optimizing for both cost and flexibility.
TUNA supports two types of comments:
Short Comments ≤280 characters stored entirely on-chain
Long Comments
280 characters with preview on-chain, full text in Walrus
The 280-character threshold balances on-chain storage costs with user experience, similar to Twitter’s original limit.
The useArticleComments hook retrieves all comments for an article:
import { useArticleComments } from '../hooks/useComments' ;
function CommentSection ({ articleId } : { articleId : string }) {
const { data : comments , isLoading } = useArticleComments ( articleId );
if ( isLoading ) return < div > Loading comments... </ div > ;
return (
< div >
{ comments ?. map ( comment => (
< CommentItem key = { comment . id } comment = { comment } />
)) }
</ div >
);
}
Comments are fetched in three steps:
Get Comment IDs from Registry
Query the engagement map to find all comment IDs for an article: const registry = await suiClient . getObject ({
id: CONTRACT_CONFIG . REGISTRY_ID ,
options: { showContent: true },
});
const fields = registry . data . content . fields as any ;
const engagementMap = fields . engagement_map ;
const contents = engagementMap . fields ?. contents || engagementMap . contents ;
// Find engagement entry for this article
const engagementEntry = contents . find (( item : any ) => {
const key = item . fields ?. key || item . key ;
return key === blobId ;
});
const value = engagementEntry . fields ?. value || engagementEntry . value ;
const valueFields = value . fields || value ;
const commentIds = valueFields . comment_ids as string [];
Fetch Comment Objects
Retrieve all comment objects in a single batch request: const commentObjects = await suiClient . multiGetObjects ({
ids: commentIds ,
options: { showContent: true }
});
Parse and Sort
Parse comment data and sort by timestamp: const parsedComments = commentObjects
. map (( obj ) => {
if ( ! obj . data ?. content ) return null ;
const fields = obj . data . content . fields as any ;
return {
id: obj . data . objectId ,
blobId: fields . blob_id ,
author: fields . author ,
text: fields . preview_text ,
timestamp: Number ( fields . timestamp ),
tipsReceived: Number ( fields . tips_received || 0 )
};
})
. filter (( c ) : c is Comment => c !== null )
. sort (( a , b ) => b . timestamp - a . timestamp ); // Newest first
export interface Comment {
id : string ; // On-chain object ID
blobId : string ; // Article blob ID
author : string ; // Commenter's address
text : string ; // Comment text (or preview)
timestamp : number ; // Unix timestamp
tipsReceived : number ; // Tips in MIST
}
For comments ≤280 characters, use the simple post function:
export function createPostCommentTransaction (
blobId : string ,
commentText : string
) : Transaction {
const tx = new Transaction ();
tx . moveCall ({
target: ` ${ CONTRACT_CONFIG . PACKAGE_ID } :: ${ CONTRACT_CONFIG . MODULE_NAME } ::post_comment` ,
arguments: [
tx . object ( CONTRACT_CONFIG . REGISTRY_ID ),
tx . pure . string ( blobId ),
tx . pure . string ( commentText ),
],
});
return tx ;
}
For comments >280 characters, upload to Walrus first:
export function createPostCommentWithBlobTransaction (
blobId : string ,
previewText : string ,
contentBlobId : string ,
commentType : 'text_long' | 'media'
) : Transaction {
const tx = new Transaction ();
tx . moveCall ({
target: ` ${ CONTRACT_CONFIG . PACKAGE_ID } :: ${ CONTRACT_CONFIG . MODULE_NAME } ::post_comment_with_blob` ,
arguments: [
tx . object ( CONTRACT_CONFIG . REGISTRY_ID ),
tx . pure . string ( blobId ),
tx . pure . string ( previewText ),
tx . pure . string ( contentBlobId ),
tx . pure . string ( commentType ),
],
});
return tx ;
}
Store the first 280 characters as preview text on-chain for quick display, with full content in Walrus.
The mutation hook handles comment submission:
import { useCurrentAccount , useSignAndExecuteTransaction } from '@mysten/dapp-kit' ;
import { Transaction } from '@mysten/sui/transactions' ;
import { useMutation , useQueryClient } from '@tanstack/react-query' ;
export function usePostComment () {
const account = useCurrentAccount ();
const { mutate : signAndExecute } = useSignAndExecuteTransaction ();
const queryClient = useQueryClient ();
return useMutation ({
mutationFn : async ({ blobId , text } : { blobId : string , text : string }) => {
if ( ! account ) throw new Error ( "Wallet not connected" );
const tx = new Transaction ();
tx . moveCall ({
target: ` ${ CONTRACT_CONFIG . PACKAGE_ID } ::news_registry::post_comment` ,
arguments: [
tx . object ( CONTRACT_CONFIG . REGISTRY_ID ),
tx . pure . string ( blobId ),
tx . pure . string ( text )
],
});
return new Promise (( resolve , reject ) => {
signAndExecute (
{ transaction: tx },
{
onSuccess : ( result ) => {
console . log ( 'Transaction success:' , result );
// Wait for indexing
setTimeout (() => resolve ( result ), 2000 );
},
onError : ( error ) => {
console . error ( 'Transaction failed:' , error );
reject ( error );
},
}
);
});
},
onSuccess : ( _ , variables ) => {
// Invalidate queries to refresh UI
queryClient . invalidateQueries ({ queryKey: [ 'comments' , variables . blobId ] });
queryClient . invalidateQueries ({ queryKey: [ 'article' , variables . blobId ] });
}
});
}
The 2-second delay after success allows the blockchain to index the new comment before refetching.
A complete comment section with form and list:
src/components/CommentSection.tsx
import { useState } from 'react' ;
import { useCurrentAccount , ConnectModal } from '@mysten/dapp-kit' ;
import { useArticleComments , usePostComment } from '../hooks/useComments' ;
export default function CommentSection ({ articleId } : { articleId : string }) {
const { data : comments , isLoading } = useArticleComments ( articleId );
const { mutate : postComment , isPending } = usePostComment ();
const account = useCurrentAccount ();
const [ commentText , setCommentText ] = useState ( '' );
const [ open , setOpen ] = useState ( false );
const handleSubmit = ( e : React . FormEvent ) => {
e . preventDefault ();
if ( ! commentText . trim ()) return ;
postComment (
{ blobId: articleId , text: commentText },
{
onSuccess : () => {
setCommentText ( '' );
},
onError : ( err ) => {
console . error ( "Failed to post comment:" , err );
alert ( "Failed to post comment." );
}
}
);
};
return (
< div className = "comments-section" >
< h3 >
Comments < span > { comments ?. length || 0 } </ span >
</ h3 >
{ /* Comment Form */ }
< div className = "comment-form" >
{ ! account ? (
< div >
< p > Login to join the conversation. </ p >
< ConnectModal
trigger = { < button > GET STARTED </ button > }
open = { open }
onOpenChange = { setOpen }
/>
</ div >
) : (
< form onSubmit = { handleSubmit } >
< textarea
value = { commentText }
onChange = { ( e ) => setCommentText ( e . target . value ) }
placeholder = "What are your thoughts on this story?"
maxLength = { 280 }
/>
< div >
< span > { commentText . length } /280 </ span >
< button type = "submit" disabled = { ! commentText . trim () || isPending } >
{ isPending ? 'POSTING...' : 'POST COMMENT' }
</ button >
</ div >
</ form >
) }
</ div >
{ /* Comments List */ }
< div className = "comments-list" >
{ isLoading ? (
< div > Loading... </ div >
) : comments && comments . length > 0 ? (
comments . map (( comment ) => (
< div key = { comment . id } className = "comment-item" >
< div >
< span > { comment . author . slice ( 0 , 6 ) } ... { comment . author . slice ( - 4 ) } </ span >
< span > {new Date ( comment . timestamp ). toLocaleDateString () } </ span >
</ div >
< p > { comment . text } </ p >
</ div >
))
) : (
< p > No comments yet. Be the first to share your thoughts! </ p >
) }
</ div >
</ div >
);
}
Character Limit Indicator
Show remaining characters with visual feedback:
< span
style = { {
color: commentText . length > 250 ? 'red' : 'gray'
} }
>
{ commentText . length } /280
</ span >
Wallet Connection Check
Only authenticated users can comment:
{ ! account ? (
< div >
< p > Login to join the conversation. </ p >
< ConnectModal trigger = { < button > GET STARTED </ button > } />
</ div >
) : (
< form onSubmit = { handleSubmit } >
{ /* Comment form */ }
</ form >
)}
Query Configuration
Configure real-time updates:
return useQuery ({
queryKey: [ 'comments' , blobId ],
queryFn : async () : Promise < Comment []> => {
// Fetch logic
},
enabled: !! blobId ,
staleTime: 0 , // Always fetch fresh data
refetchOnWindowFocus: true // Refresh when tab gains focus
});
Setting staleTime: 0 ensures users always see the latest comments when they navigate to an article.
For comments exceeding 280 characters:
Prepare Comment Content
const commentContent : WalrusCommentContent = {
text: longCommentText ,
timestamp: Date . now (),
author: account . address
};
Upload to Walrus
import { uploadToWalrus } from '../lib/walrus' ;
const blobId = await uploadToWalrus ( commentContent );
Create Preview Text
const previewText = longCommentText . substring ( 0 , 280 );
Post Comment with Blob
const tx = createPostCommentWithBlobTransaction (
articleId ,
previewText ,
blobId ,
'text_long'
);
Format author addresses for readability:
< span >
{ comment . author . slice ( 0 , 6 ) } ... { comment . author . slice ( - 4 ) }
</ span >
Format timestamps:
< span >
{new Date ( comment . timestamp ). toLocaleDateString () }
</ span >
Error Handling
Handle various error scenarios:
const handleSubmit = async ( e : React . FormEvent ) => {
e . preventDefault ();
if ( ! commentText . trim ()) {
alert ( 'Comment cannot be empty' );
return ;
}
if ( commentText . length > 280 ) {
// Handle long comment flow
return ;
}
postComment (
{ blobId: articleId , text: commentText },
{
onSuccess : () => {
setCommentText ( '' );
},
onError : ( err ) => {
console . error ( 'Failed to post comment:' , err );
alert ( 'Failed to post comment. Please try again.' );
}
}
);
};
Best Practices
Validate Input Always validate comment text before submission
Handle Loading States Show loading indicators during submission
Optimistic Updates Consider showing comments immediately before confirmation
Cache Invalidation Invalidate queries after successful posts
Next Steps
Article Tipping Add tipping functionality to comments
Wallet Connection Learn about wallet integration