How to Build a Web3 Community App: Token-Gated Chat, Voice Rooms & DAO Governance

Every successful Web3 project shares one trait: a thriving community app that keeps token holders engaged, informed, and actively governing the protocol. Yet most teams still rely on Discord bots and Telegram groups stitched together with fragile integrations. The result? Fragmented conversations, zero on-chain identity, and no real ownership for members.
This tutorial takes a different path. You will build a native Web3 community app from scratch—one that verifies token ownership at the gate, runs DAO governance votes in-app, and provides real-time voice rooms for community calls. The entire real-time communication layer is powered by Tencent RTC (TRTC) SDKs, so you ship production-quality messaging and audio without reinventing WebRTC infrastructure.
By the end, you will have a working MVP with:
- Token-gated chat channels (ERC-20 / ERC-721 verification)
- Live voice rooms for AMAs, governance debates, and contributor standups
- On-chain proposal creation and voting
- Push notifications for governance deadlines and community events
- NFT-based membership tiers with differentiated access
What Is a Community App in Web3?
Before diving into code, let's clarify what is community app in the Web3 context and how it differs fundamentally from traditional platforms.
A traditional community app—Facebook Groups, Slack, or Discord—centralizes identity, data, and moderation power on a single company's servers. Users have no portable reputation, no ownership of their content, and no governance power over the platform itself.
A Web3 community app inverts this model:
| Dimension | Traditional Community App | Web3 Community App |
|---|---|---|
| Identity | Email/password | Wallet address + ENS/DID |
| Access control | Admin invites | Token-gated (hold NFT or ERC-20 to enter) |
| Governance | Admins decide everything | DAO proposals + on-chain voting |
| Membership | Free or subscription | NFT membership passes |
| Data ownership | Platform owns data | User owns keys, content on IPFS/Arweave |
| Reputation | Follower count | On-chain activity, POAPs, SBTs |
| Monetization | Ads, subscriptions | Token incentives, treasury-funded |
The in app community experience in Web3 means members interact inside a single interface where their wallet identity unlocks channels, voting power, and reputation—without switching between five different tools. Every interaction carries the weight of their on-chain history.
Why Build Your Own Instead of Using Discord?
Discord works for early communities, but it has critical limitations once a Web3 project matures:
- No native wallet auth — You need third-party bots (Collab.Land, Guild.xyz) that break frequently during high-traffic moments like mints and governance votes
- No on-chain governance integration — Snapshot links take users out of the conversation, fracturing the decision-making flow
- Platform risk — Discord has banned crypto servers without appeal, wiping months of community history
- No ownership — Members' reputations, contribution history, and social graphs disappear if the server closes
- Voice quality limitations — Discord's voice is optimized for gaming, not professional community calls with hundreds of listeners
- No token economics — Can't reward community participation with tokens or use holdings for access tiering
Building your own community app gives you full control over the user experience, data, and monetization model. With TRTC's Chat SDK and Voice Room infrastructure handling the hard parts of real-time communication, you focus on what makes your community unique: the Web3 integration layer.
Architecture Overview
Here's the high-level architecture of our Web3 community app:
┌─────────────────────────────────────────────────────────┐
│ Frontend (React + ethers.js) │
│ Wallet Connect │ Chat UI │ Voice Room │ DAO UI │
└────────┬─────────┴─────┬─────┴──────┬───────┴────┬──────┘
│ │ │ │
┌────────▼───────┐ ┌─────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
│ Smart Contracts│ │ TRTC Chat │ │TRTC Voice│ │Governance│
│ (Access Gate) │ │ SDK │ │Room SDK │ │ Contract │
│ (Membership) │ │ │ │ │ │ │
└────────────────┘ └───────────┘ └──────────┘ └──────────┘
│ │
└──────────────── Ethereum / L2 ───────────┘Key technology decisions:
- Frontend: React + TypeScript + ethers.js for wallet interaction
- Backend: Node.js for token verification and TRTC UserSig generation
- Communication: TRTC Chat SDK for messaging, TRTC Voice Room for live audio
- Blockchain: Ethereum or any EVM chain (Polygon, Arbitrum, Base) for token gating and governance
- Storage: IPFS for proposal metadata, PostgreSQL for app state
Step 1: Project Setup and Dependencies
Start by scaffolding the project with Web3 and real-time communication dependencies:
# Create the project
npx create-react-app web3-community --template typescript
cd web3-community
# Web3 dependencies
npm install ethers @walletconnect/web3-provider @web3modal/ethers
# TRTC SDKs for real-time communication
npm install tim-js-sdk trtc-js-sdk
# Smart contract development
npm install --save-dev hardhat @openzeppelin/contracts @nomicfoundation/hardhat-toolbox
# State management and utilities
npm install zustand axiosProject structure:
src/
├── components/
│ ├── chat/ # Chat channels, message list, input
│ ├── voice/ # Voice room UI, speaker management
│ ├── governance/ # Proposal cards, voting interface
│ └── auth/ # Wallet connect, token gate prompts
├── contracts/ # Solidity smart contracts
├── hooks/ # Custom React hooks (useWallet, useTokenGate)
├── services/
│ ├── chat.ts # TRTC Chat SDK wrapper
│ ├── voiceRoom.ts # TRTC Voice Room wrapper
│ ├── tokenGate.ts # On-chain verification
│ └── governance.ts # Proposal and voting logic
├── config/
│ └── contracts.ts # ABIs and deployed addresses
└── App.tsxStep 2: Wallet Authentication and Identity
Every Web3 community app starts with wallet-based authentication. This replaces email/password with cryptographic proof of identity—your wallet address becomes your username across the entire ecosystem.
// src/hooks/useWallet.ts
import { ethers } from 'ethers';
import { useState, useCallback } from 'react';
interface WalletSession {
address: string;
userSig: string; // TRTC authentication signature
ensName?: string;
}
export function useWallet() {
const [session, setSession] = useState<WalletSession | null>(null);
const [provider, setProvider] = useState<ethers.BrowserProvider | null>(null);
const connect = useCallback(async () => {
if (!window.ethereum) {
throw new Error('No wallet detected. Install MetaMask or another Web3 wallet.');
}
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const accounts = await browserProvider.send('eth_requestAccounts', []);
const walletAddress = accounts[0].toLowerCase();
setProvider(browserProvider);
// Sign a message to prove wallet ownership
const signer = await browserProvider.getSigner();
const nonce = await fetchNonce(walletAddress);
const signature = await signer.signMessage(
`Sign in to Web3 Community\nNonce: ${nonce}\nTimestamp: ${Date.now()}`
);
// Exchange signature for session token + TRTC UserSig
const response = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: walletAddress, signature, nonce })
});
const { userSig, ensName } = await response.json();
const walletSession: WalletSession = {
address: walletAddress,
userSig,
ensName
};
setSession(walletSession);
return walletSession;
}, []);
const disconnect = useCallback(() => {
setSession(null);
setProvider(null);
}, []);
return { session, provider, connect, disconnect };
}
async function fetchNonce(address: string): Promise<string> {
const res = await fetch(`/api/auth/nonce?address=${address}`);
const { nonce } = await res.json();
return nonce;
}The wallet address becomes the user's universal identity. No emails, no passwords—just cryptographic proof of ownership. This identity persists across every channel, voice room, and governance vote within your community app.
Step 3: Token-Gated Access Control
The defining feature of a Web3 community is token-gated access. Members must hold specific tokens (ERC-20, ERC-721, or ERC-1155) to enter channels or access premium features. This is what transforms a generic community text app into a Web3-native platform.
Smart Contract: Community Access Gate
// contracts/CommunityGate.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract CommunityGate is Ownable {
struct GateRule {
address tokenContract;
uint256 minBalance; // Minimum tokens required
bool isERC721; // true = NFT, false = ERC-20
bool active;
uint256 chainId; // Support multi-chain verification
}
// channelId => GateRule
mapping(string => GateRule) public channelGates;
// Track all gated channels
string[] public gatedChannels;
event GateCreated(string indexed channelId, address tokenContract, uint256 minBalance);
event GateRemoved(string indexed channelId);
event AccessVerified(string indexed channelId, address indexed user, bool granted);
constructor() Ownable(msg.sender) {}
function setGate(
string calldata channelId,
address tokenContract,
uint256 minBalance,
bool isERC721,
uint256 chainId
) external onlyOwner {
channelGates[channelId] = GateRule({
tokenContract: tokenContract,
minBalance: minBalance,
isERC721: isERC721,
active: true,
chainId: chainId
});
gatedChannels.push(channelId);
emit GateCreated(channelId, tokenContract, minBalance);
}
function removeGate(string calldata channelId) external onlyOwner {
channelGates[channelId].active = false;
emit GateRemoved(channelId);
}
function verifyAccess(
string calldata channelId,
address user
) external view returns (bool) {
GateRule memory gate = channelGates[channelId];
// No active gate means open access
if (!gate.active) return true;
uint256 balance;
if (gate.isERC721) {
balance = IERC721(gate.tokenContract).balanceOf(user);
} else {
balance = IERC20(gate.tokenContract).balanceOf(user);
}
return balance >= gate.minBalance;
}
function getGateInfo(string calldata channelId) external view returns (
address tokenContract,
uint256 minBalance,
bool isERC721,
bool active
) {
GateRule memory gate = channelGates[channelId];
return (gate.tokenContract, gate.minBalance, gate.isERC721, gate.active);
}
}Backend: Token Verification Middleware
The frontend calls the smart contract to check access, but the backend must also verify before granting TRTC group membership—never trust the client alone.
// server/middleware/tokenGate.ts
import { ethers } from 'ethers';
import { COMMUNITY_GATE_ABI, COMMUNITY_GATE_ADDRESS } from '../config/contracts';
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const gateContract = new ethers.Contract(
COMMUNITY_GATE_ADDRESS,
COMMUNITY_GATE_ABI,
provider
);
export async function verifyTokenAccess(
channelId: string,
walletAddress: string
): Promise<{ hasAccess: boolean; gateInfo?: any }> {
try {
const hasAccess = await gateContract.verifyAccess(channelId, walletAddress);
if (!hasAccess) {
const gateInfo = await gateContract.getGateInfo(channelId);
return {
hasAccess: false,
gateInfo: {
tokenContract: gateInfo.tokenContract,
minBalance: gateInfo.minBalance.toString(),
isERC721: gateInfo.isERC721
}
};
}
return { hasAccess: true };
} catch (error) {
console.error('Token verification failed:', error);
return { hasAccess: false };
}
}
// Express middleware for API routes
export function tokenGateMiddleware() {
return async (req: any, res: any, next: any) => {
const { channelId } = req.params;
const { walletAddress } = req.user; // Set by auth middleware
const { hasAccess, gateInfo } = await verifyTokenAccess(channelId, walletAddress);
if (!hasAccess) {
return res.status(403).json({
error: 'TOKEN_REQUIRED',
message: 'You need to hold the required token to access this channel',
gate: gateInfo
});
}
next();
};
}Frontend: Gated Channel Component
// src/components/auth/GatedChannel.tsx
import { useState, useEffect } from 'react';
import { useWallet } from '../../hooks/useWallet';
import { verifyTokenAccess } from '../../services/tokenGate';
interface GatedChannelProps {
channelId: string;
children: React.ReactNode;
}
export function GatedChannel({ channelId, children }: GatedChannelProps) {
const { session } = useWallet();
const [access, setAccess] = useState<'loading' | 'granted' | 'denied'>('loading');
const [gateInfo, setGateInfo] = useState<any>(null);
useEffect(() => {
async function checkAccess() {
if (!session?.address) {
setAccess('denied');
return;
}
const result = await verifyTokenAccess(channelId, session.address);
if (result.hasAccess) {
setAccess('granted');
} else {
setGateInfo(result.gateInfo);
setAccess('denied');
}
}
checkAccess();
}, [session?.address, channelId]);
if (access === 'loading') {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-pulse text-gray-400">Verifying token holdings...</div>
</div>
);
}
if (access === 'denied') {
return (
<div className="flex flex-col items-center justify-center h-full p-8">
<div className="w-16 h-16 bg-gray-800 rounded-full flex items-center justify-center mb-4">
🔒
</div>
<h3 className="text-xl font-bold text-white mb-2">Token-Gated Channel</h3>
<p className="text-gray-400 text-center mb-4">
Hold {gateInfo?.minBalance} {gateInfo?.isERC721 ? 'NFT(s)' : 'tokens'} to unlock this channel.
</p>
<p className="text-sm text-gray-500 font-mono mb-4">
Contract: {gateInfo?.tokenContract?.slice(0, 10)}...
</p>
<a
href={`https://app.uniswap.org/#/swap?outputCurrency=${gateInfo?.tokenContract}`}
target="_blank"
rel="noopener noreferrer"
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Get Tokens →
</a>
</div>
);
}
return <>{children}</>;
}This pattern works for any channel—general discussion, alpha calls, governance-only rooms, or NFT holder lounges. The smart contract is the bouncer: hold the token, get access. No admin approval needed.
Step 4: Real-Time Chat with TRTC Chat SDK
With access control in place, integrate TRTC's Chat SDK for real-time messaging. TRTC Chat handles message delivery, offline sync, read receipts, and group management—so you focus on the Web3 experience layer.
Initialize the Chat SDK
// src/services/chat.ts
import TIM from 'tim-js-sdk';
let tim: any = null;
export async function initChat(userId: string, userSig: string) {
tim = TIM.create({
SDKAppID: Number(process.env.REACT_APP_TRTC_APP_ID)
});
// Set log level (0 = minimal in production)
tim.setLogLevel(0);
// Login with wallet address as userId
await tim.login({ userID: userId, userSig });
// Set up event listeners
tim.on(TIM.EVENT.MESSAGE_RECEIVED, (event: any) => {
const messages = event.data;
handleIncomingMessages(messages);
});
tim.on(TIM.EVENT.GROUP_SYSTEM_NOTICE_RECEIVED, (event: any) => {
handleGroupNotice(event.data);
});
tim.on(TIM.EVENT.KICKED_OUT, (event: any) => {
// Session invalidated — wallet may have been used elsewhere
handleSessionExpired(event.data.type);
});
return tim;
}
export function getChatInstance() {
return tim;
}
function handleIncomingMessages(messages: any[]) {
// Dispatch to your state manager (Zustand, Redux, etc.)
const store = useChatStore.getState();
messages.forEach(msg => {
store.addMessage(msg.conversationID, msg);
});
}Create Token-Gated Groups (Channels)
Each channel in your community app maps to a TRTC Chat group. The group type determines behavior and scale:
// src/services/channels.ts
import TIM from 'tim-js-sdk';
import { getChatInstance } from './chat';
/**
* Create a community channel (supports up to 100,000 members).
* Group ID matches the channelId used in the smart contract gate.
*/
export async function createCommunityChannel(params: {
channelId: string;
name: string;
description: string;
tokenGate?: {
contractAddress: string;
minBalance: number;
chainId: number;
};
}) {
const { channelId, name, description, tokenGate } = params;
const chat = getChatInstance();
// Create the group on TRTC Chat
const result = await chat.createGroup({
type: TIM.TYPES.GRP_COMMUNITY, // Community: large-scale, topic-based
groupID: channelId,
name: name,
introduction: description,
notification: tokenGate
? `🔒 Token-gated: Hold ${tokenGate.minBalance} tokens at ${tokenGate.contractAddress}`
: '🌐 Open channel — all members welcome',
joinOption: TIM.TYPES.JOIN_OPTIONS_NEED_PERMISSION
});
// Create topic threads within the community group
await chat.createTopicInCommunity({
groupID: channelId,
topicName: 'General',
topicID: `${channelId}_general`
});
await chat.createTopicInCommunity({
groupID: channelId,
topicName: 'Governance',
topicID: `${channelId}_governance`
});
await chat.createTopicInCommunity({
groupID: channelId,
topicName: 'Development',
topicID: `${channelId}_dev`
});
// Store gate rules in backend for server-side verification
if (tokenGate) {
await fetch('/api/gates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId, ...tokenGate })
});
}
return result.data.group;
}
/**
* Join a channel after token-gate verification passes
*/
export async function joinChannel(channelId: string) {
const chat = getChatInstance();
await chat.joinGroup({
groupID: channelId,
type: TIM.TYPES.GRP_COMMUNITY
});
}
/**
* Send a text message with on-chain metadata
*/
export async function sendMessage(
topicId: string,
text: string,
senderMeta: {
ensName?: string;
nftAvatar?: string;
membershipTier?: string;
governancePower?: number;
}
) {
const chat = getChatInstance();
const message = chat.createTextMessage({
to: topicId,
conversationType: TIM.TYPES.CONV_GROUP,
payload: { text },
// Attach Web3 identity data as custom metadata
cloudCustomData: JSON.stringify({
ens: senderMeta.ensName,
avatar: senderMeta.nftAvatar,
tier: senderMeta.membershipTier,
power: senderMeta.governancePower,
ts: Date.now()
})
});
const res = await chat.sendMessage(message);
return res.data.message;
}
/**
* Send a governance proposal notification to a channel
*/
export async function broadcastProposal(channelId: string, proposal: {
id: string;
title: string;
summary: string;
votingDeadline: number;
}) {
const chat = getChatInstance();
const message = chat.createCustomMessage({
to: `${channelId}_governance`,
conversationType: TIM.TYPES.CONV_GROUP,
payload: {
data: JSON.stringify({
type: 'GOVERNANCE_PROPOSAL',
proposal
}),
description: `🗳️ New Proposal: ${proposal.title}`,
extension: 'VOTE_CARD'
}
});
await chat.sendMessage(message);
}
/**
* Fetch message history for a topic
*/
export async function getMessageHistory(topicId: string, nextReqMessageID?: string) {
const chat = getChatInstance();
const result = await chat.getMessageList({
conversationID: `GROUP${topicId}`,
nextReqMessageID,
count: 30
});
return {
messages: result.data.messageList,
nextReqMessageID: result.data.nextReqMessageID,
isCompleted: result.data.isCompleted
};
}Chat UI Component
// src/components/chat/ChatChannel.tsx
import { useState, useEffect, useRef } from 'react';
import { getChatInstance } from '../../services/chat';
import { sendMessage, getMessageHistory } from '../../services/channels';
import { useWallet } from '../../hooks/useWallet';
import TIM from 'tim-js-sdk';
interface ChatChannelProps {
channelId: string;
}
export function ChatChannel({ channelId }: ChatChannelProps) {
const { session } = useWallet();
const [messages, setMessages] = useState<any[]>([]);
const [input, setInput] = useState('');
const [activeTopic, setActiveTopic] = useState(`${channelId}_general`);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const chat = getChatInstance();
if (!chat) return;
// Load message history
getMessageHistory(activeTopic).then(({ messages: history }) => {
setMessages(history);
scrollToBottom();
});
// Listen for new messages in this topic
const onMessageReceived = (event: any) => {
const newMessages = event.data.filter(
(msg: any) => msg.conversationID === `GROUP${activeTopic}`
);
if (newMessages.length > 0) {
setMessages(prev => [...prev, ...newMessages]);
scrollToBottom();
}
};
chat.on(TIM.EVENT.MESSAGE_RECEIVED, onMessageReceived);
return () => { chat.off(TIM.EVENT.MESSAGE_RECEIVED, onMessageReceived); };
}, [activeTopic]);
const scrollToBottom = () => {
setTimeout(() => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100);
};
const handleSend = async () => {
if (!input.trim() || !session) return;
await sendMessage(activeTopic, input, {
ensName: session.ensName,
membershipTier: 'holder'
});
setInput('');
};
const parseMetadata = (msg: any) => {
try {
return JSON.parse(msg.cloudCustomData || '{}');
} catch {
return {};
}
};
return (
<div className="flex flex-col h-full bg-gray-950">
{/* Topic tabs */}
<div className="flex gap-2 p-3 border-b border-gray-800">
{['general', 'governance', 'dev'].map(topic => (
<button
key={topic}
onClick={() => setActiveTopic(`${channelId}_${topic}`)}
className={`px-3 py-1.5 rounded-md text-sm ${
activeTopic === `${channelId}_${topic}`
? 'bg-purple-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
#{topic}
</button>
))}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, i) => {
const meta = parseMetadata(msg);
return (
<div key={msg.ID || i} className="flex gap-3">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-purple-500 to-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-purple-300">
{meta.ens || `${msg.from?.slice(0, 6)}...${msg.from?.slice(-4)}`}
</span>
{meta.tier && (
<span className="text-xs px-1.5 py-0.5 bg-purple-900/50 text-purple-300 rounded">
{meta.tier}
</span>
)}
<span className="text-xs text-gray-600">
{new Date(msg.time * 1000).toLocaleTimeString()}
</span>
</div>
<p className="text-gray-100 mt-0.5">{msg.payload?.text}</p>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 border-t border-gray-800">
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder={`Message #${activeTopic.split('_').pop()}...`}
className="flex-1 bg-gray-800 rounded-lg px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
/>
<button
onClick={handleSend}
className="px-5 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium"
>
Send
</button>
</div>
</div>
</div>
);
}Step 5: Voice Rooms for DAO Calls and Community Hangouts
Live voice is what transforms a community text app into a living, breathing community. TRTC's Voice Room provides low-latency audio with anchor/audience role separation—perfect for structured DAO governance calls where proposal authors present while members listen, then raise hands to speak.
Voice Room Implementation
// src/services/voiceRoom.ts
import TRTC from 'trtc-js-sdk';
let client: any = null;
let localStream: any = null;
interface VoiceRoomConfig {
roomId: number;
userId: string;
userSig: string;
role: 'anchor' | 'audience';
}
/**
* Enter a voice room with specified role.
* - scene: 'live' enables anchor/audience role separation
* - role: 'anchor' can publish audio (speak)
* - role: 'audience' can only subscribe (listen)
*/
export async function enterVoiceRoom(config: VoiceRoomConfig) {
const { roomId, userId, userSig, role } = config;
client = TRTC.createClient({
sdkAppId: Number(process.env.REACT_APP_TRTC_APP_ID),
userId,
userSig,
mode: 'live', // Live mode supports anchor/audience roles
scene: 'live'
});
// Handle remote streams (other speakers)
client.on('stream-added', (event: any) => {
const remoteStream = event.stream;
// Only subscribe to audio in a voice room
client.subscribe(remoteStream, { audio: true, video: false });
});
client.on('stream-subscribed', (event: any) => {
const remoteStream = event.stream;
remoteStream.play('remote-audio-container');
notifySpeakerJoined(remoteStream.getUserId());
});
client.on('stream-removed', (event: any) => {
notifySpeakerLeft(event.stream.getUserId());
});
client.on('peer-join', (event: any) => {
notifyListenerJoined(event.userId);
});
client.on('peer-leave', (event: any) => {
notifyListenerLeft(event.userId);
});
// Enter the room with specified role
await client.join({ roomId, role });
// If anchor, immediately publish audio
if (role === 'anchor') {
await publishLocalAudio(userId);
}
return client;
}
/**
* Publish local audio stream (for anchors/speakers only)
*/
async function publishLocalAudio(userId: string) {
localStream = TRTC.createStream({
userId,
audio: true,
video: false // Voice-only — no camera
});
await localStream.initialize();
await client.publish(localStream);
}
/**
* Request to speak: switch from audience to anchor.
* In a DAO call, this is "raising your hand."
*/
export async function requestToSpeak() {
if (!client) throw new Error('Not in a voice room');
// Switch role from audience to anchor
await client.switchRole('anchor');
// Now publish audio
localStream = TRTC.createStream({
userId: client.userId_,
audio: true,
video: false
});
await localStream.initialize();
await client.publish(localStream);
}
/**
* Step down: stop speaking and return to audience role.
*/
export async function stepDown() {
if (!client || !localStream) return;
await client.unpublish(localStream);
localStream.close();
localStream = null;
await client.switchRole('audience');
}
/**
* Toggle mute while remaining as anchor
*/
export function toggleMute(muted: boolean) {
if (!localStream) return;
muted ? localStream.muteAudio() : localStream.unmuteAudio();
}
/**
* Leave the voice room entirely
*/
export async function leaveVoiceRoom() {
if (localStream) {
await client.unpublish(localStream);
localStream.close();
localStream = null;
}
if (client) {
await client.leave();
client = null;
}
}
// Notification callbacks (connect to your state manager)
function notifySpeakerJoined(userId: string) {
useChatStore.getState().addSpeaker(userId);
}
function notifySpeakerLeft(userId: string) {
useChatStore.getState().removeSpeaker(userId);
}
function notifyListenerJoined(userId: string) {
useChatStore.getState().addListener(userId);
}
function notifyListenerLeft(userId: string) {
useChatStore.getState().removeListener(userId);
}Voice Room UI Component
// src/components/voice/VoiceRoom.tsx
import { useState, useEffect } from 'react';
import {
enterVoiceRoom,
leaveVoiceRoom,
requestToSpeak,
stepDown,
toggleMute
} from '../../services/voiceRoom';
import { useWallet } from '../../hooks/useWallet';
import { verifyTokenAccess } from '../../services/tokenGate';
interface VoiceRoomProps {
roomId: number;
channelId: string;
title: string;
description?: string;
}
export function VoiceRoom({ roomId, channelId, title, description }: VoiceRoomProps) {
const { session } = useWallet();
const [isInRoom, setIsInRoom] = useState(false);
const [isSpeaker, setIsSpeaker] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [speakers, setSpeakers] = useState<string[]>([]);
const [listeners, setListeners] = useState<string[]>([]);
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
// Check token gate on mount
useEffect(() => {
if (!session?.address) return;
verifyTokenAccess(channelId, session.address).then(result => {
setHasAccess(result.hasAccess);
});
}, [session?.address, channelId]);
const handleJoin = async () => {
if (!session || !hasAccess) return;
await enterVoiceRoom({
roomId,
userId: session.address,
userSig: session.userSig,
role: 'audience' // Always join as listener first
});
setIsInRoom(true);
};
const handleRaiseHand = async () => {
await requestToSpeak();
setIsSpeaker(true);
};
const handleStepDown = async () => {
await stepDown();
setIsSpeaker(false);
setIsMuted(false);
};
const handleToggleMute = () => {
const newMuted = !isMuted;
toggleMute(newMuted);
setIsMuted(newMuted);
};
const handleLeave = async () => {
await leaveVoiceRoom();
setIsInRoom(false);
setIsSpeaker(false);
setIsMuted(false);
};
if (hasAccess === false) {
return (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h3 className="text-lg font-bold text-white">{title}</h3>
<p className="text-gray-500 mt-2">🔒 Token required to join this voice room</p>
</div>
);
}
return (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-bold text-white">{title}</h3>
{description && <p className="text-sm text-gray-400 mt-1">{description}</p>}
</div>
{isInRoom && (
<span className="flex items-center gap-1.5 text-xs text-green-400">
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
LIVE
</span>
)}
</div>
{/* Speakers section */}
{isInRoom && speakers.length > 0 && (
<div className="mb-4">
<p className="text-xs uppercase text-gray-500 mb-2">Speakers</p>
<div className="flex flex-wrap gap-3">
{speakers.map(addr => (
<div key={addr} className="flex flex-col items-center">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 ring-2 ring-green-400" />
<span className="text-xs text-gray-400 mt-1 font-mono">
{addr.slice(0, 6)}
</span>
</div>
))}
</div>
</div>
)}
{/* Listeners section */}
{isInRoom && listeners.length > 0 && (
<div className="mb-4">
<p className="text-xs uppercase text-gray-500 mb-2">
Listening ({listeners.length})
</p>
<div className="flex flex-wrap gap-2">
{listeners.slice(0, 12).map(addr => (
<div key={addr} className="w-8 h-8 rounded-full bg-gray-700" title={addr} />
))}
{listeners.length > 12 && (
<div className="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs text-gray-400">
+{listeners.length - 12}
</div>
)}
</div>
</div>
)}
{/* Controls */}
<div className="flex gap-3 mt-4">
{!isInRoom ? (
<button
onClick={handleJoin}
className="px-5 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium"
>
Join Room
</button>
) : (
<>
{!isSpeaker ? (
<button
onClick={handleRaiseHand}
className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600"
>
✋ Raise Hand
</button>
) : (
<>
<button
onClick={handleToggleMute}
className={`px-4 py-2 rounded-lg ${
isMuted ? 'bg-red-900 text-red-300' : 'bg-gray-700 text-white'
}`}
>
{isMuted ? '🔇 Unmute' : '🎙️ Mute'}
</button>
<button
onClick={handleStepDown}
className="px-4 py-2 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600"
>
Step Down
</button>
</>
)}
<button
onClick={handleLeave}
className="px-4 py-2 bg-red-600/20 text-red-400 rounded-lg hover:bg-red-600/30 ml-auto"
>
Leave
</button>
</>
)}
</div>
{/* Hidden audio container for remote streams */}
<div id='remote-audio-container' className="hidden" />
</div>
);
}Step 6: DAO Governance Integration
Governance is what makes a Web3 community app truly community-owned. Members don't just chat—they propose, debate, and vote on protocol decisions. Embedding governance directly in the communication layer means proposals get discussed and voted on in the same interface, eliminating the fragmentation of switching between Snapshot, Discord, and a governance dashboard.
Governance Smart Contract
// contracts/CommunityGovernance.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract CommunityGovernance {
IERC20 public governanceToken;
struct Proposal {
uint256 id;
address proposer;
string title;
string metadataURI; // IPFS hash for full proposal text
uint256 startBlock;
uint256 endBlock;
uint256 forVotes;
uint256 againstVotes;
uint256 abstainVotes;
bool executed;
}
uint256 public proposalCount;
mapping(uint256 => Proposal) public proposals;
mapping(uint256 => mapping(address => bool)) public hasVoted;
// Minimum tokens to create a proposal
uint256 public constant PROPOSAL_THRESHOLD = 1000 * 1e18;
// Voting period in blocks (~7 days on Ethereum)
uint256 public constant VOTING_PERIOD = 50400;
event ProposalCreated(
uint256 indexed id,
address indexed proposer,
string title,
uint256 endBlock
);
event VoteCast(
uint256 indexed proposalId,
address indexed voter,
uint8 support,
uint256 weight
);
event ProposalExecuted(uint256 indexed id);
constructor(address _governanceToken) {
governanceToken = IERC20(_governanceToken);
}
function propose(
string calldata title,
string calldata metadataURI
) external returns (uint256) {
require(
governanceToken.balanceOf(msg.sender) >= PROPOSAL_THRESHOLD,
"Insufficient tokens to propose"
);
proposalCount++;
proposals[proposalCount] = Proposal({
id: proposalCount,
proposer: msg.sender,
title: title,
metadataURI: metadataURI,
startBlock: block.number,
endBlock: block.number + VOTING_PERIOD,
forVotes: 0,
againstVotes: 0,
abstainVotes: 0,
executed: false
});
emit ProposalCreated(proposalCount, msg.sender, title, block.number + VOTING_PERIOD);
return proposalCount;
}
function vote(uint256 proposalId, uint8 support) external {
Proposal storage p = proposals[proposalId];
require(block.number >= p.startBlock, "Voting not started");
require(block.number <= p.endBlock, "Voting ended");
require(!hasVoted[proposalId][msg.sender], "Already voted");
uint256 weight = governanceToken.balanceOf(msg.sender);
require(weight > 0, "No voting power");
hasVoted[proposalId][msg.sender] = true;
if (support == 0) {
p.againstVotes += weight;
} else if (support == 1) {
p.forVotes += weight;
} else {
p.abstainVotes += weight;
}
emit VoteCast(proposalId, msg.sender, support, weight);
}
function getProposal(uint256 proposalId) external view returns (
string memory title,
address proposer,
uint256 forVotes,
uint256 againstVotes,
uint256 abstainVotes,
uint256 endBlock,
bool active
) {
Proposal memory p = proposals[proposalId];
bool isActive = block.number >= p.startBlock && block.number <= p.endBlock;
return (p.title, p.proposer, p.forVotes, p.againstVotes, p.abstainVotes, p.endBlock, isActive);
}
}In-App Voting Component
// src/components/governance/ProposalCard.tsx
import { useState } from 'react';
import { ethers } from 'ethers';
import { GOVERNANCE_ABI, GOVERNANCE_ADDRESS } from '../../config/contracts';
import { useWallet } from '../../hooks/useWallet';
import { broadcastProposal } from '../../services/channels';
interface Proposal {
id: number;
title: string;
proposer: string;
forVotes: bigint;
againstVotes: bigint;
abstainVotes: bigint;
endBlock: number;
active: boolean;
metadataURI: string;
}
export function ProposalCard({ proposal, channelId }: { proposal: Proposal; channelId: string }) {
const { provider } = useWallet();
const [voting, setVoting] = useState(false);
const [voted, setVoted] = useState(false);
const castVote = async (support: 0 | 1 | 2) => {
if (!provider) return;
setVoting(true);
try {
const signer = await provider.getSigner();
const contract = new ethers.Contract(GOVERNANCE_ADDRESS, GOVERNANCE_ABI, signer);
const tx = await contract.vote(proposal.id, support);
await tx.wait();
setVoted(true);
// Announce vote in governance channel
await broadcastProposal(channelId, {
id: String(proposal.id),
title: proposal.title,
summary: `Vote cast: ${support === 1 ? 'For' : support === 0 ? 'Against' : 'Abstain'}`,
votingDeadline: proposal.endBlock
});
} catch (error) {
console.error('Vote failed:', error);
} finally {
setVoting(false);
}
};
const totalVotes = proposal.forVotes + proposal.againstVotes + proposal.abstainVotes;
const forPercent = totalVotes > 0n
? Number((proposal.forVotes * 10000n) / totalVotes) / 100
: 0;
const againstPercent = totalVotes > 0n
? Number((proposal.againstVotes * 10000n) / totalVotes) / 100
: 0;
return (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-start justify-between mb-3">
<div>
<span className={`text-xs px-2 py-0.5 rounded ${
proposal.active ? 'bg-green-900 text-green-300' : 'bg-gray-700 text-gray-400'
}`}>
{proposal.active ? 'Active' : 'Closed'}
</span>
<h3 className="text-lg font-bold text-white mt-2">{proposal.title}</h3>
<p className="text-sm text-gray-500 font-mono mt-1">
by {proposal.proposer.slice(0, 8)}...{proposal.proposer.slice(-6)}
</p>
</div>
<span className="text-sm text-gray-400">#{proposal.id}</span>
</div>
{/* Vote progress */}
<div className="mt-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-green-400">For: {forPercent.toFixed(1)}%</span>
<span className="text-red-400">Against: {againstPercent.toFixed(1)}%</span>
</div>
<div className="w-full h-2 bg-gray-800 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${forPercent}%` }} />
<div className="bg-red-500 h-full" style={{ width: `${againstPercent}%` }} />
</div>
<p className="text-xs text-gray-500">
{ethers.formatEther(totalVotes)} total votes cast
</p>
</div>
{/* Voting buttons */}
{proposal.active && !voted && (
<div className="flex gap-3 mt-5">
<button
onClick={() => castVote(1)}
disabled={voting}
className="flex-1 py-2.5 bg-green-600/20 text-green-400 rounded-lg hover:bg-green-600/30 font-medium disabled:opacity-50"
>
👍 For
</button>
<button
onClick={() => castVote(0)}
disabled={voting}
className="flex-1 py-2.5 bg-red-600/20 text-red-400 rounded-lg hover:bg-red-600/30 font-medium disabled:opacity-50"
>
👎 Against
</button>
<button
onClick={() => castVote(2)}
disabled={voting}
className="flex-1 py-2.5 bg-gray-600/20 text-gray-400 rounded-lg hover:bg-gray-600/30 font-medium disabled:opacity-50"
>
🤷 Abstain
</button>
</div>
)}
{voted && (
<div className="mt-4 p-3 bg-purple-900/30 rounded-lg text-center">
<p className="text-purple-300 text-sm">✅ Your vote has been recorded on-chain</p>
</div>
)}
</div>
);
}Step 7: Push Notifications
Push notifications keep members engaged with governance deadlines, voice room events, and important announcements. Without them, your in app community goes silent between active sessions.
Backend: Notification Service
// server/services/notifications.ts
interface PushPayload {
userIds: string[];
title: string;
body: string;
data?: Record<string, any>;
}
/**
* Send push notifications via TRTC offline push + Web Push API
*/
export async function sendPushNotification({ userIds, title, body, data }: PushPayload) {
// Use TRTC's custom message with offline push info for mobile
const TIMAdmin = getTIMAdminInstance();
for (const userId of userIds) {
await TIMAdmin.sendMessage({
From_Account: 'system_admin',
To_Account: userId,
MsgBody: [{
MsgType: 'TIMCustomElem',
MsgContent: {
Data: JSON.stringify({ type: 'NOTIFICATION', title, body, ...data }),
Desc: body,
Ext: 'push_notification'
}
}],
OfflinePushInfo: {
PushFlag: 0,
Title: title,
Desc: body,
Ext: JSON.stringify(data),
ApnsInfo: { Sound: 'default', BadgeMode: 0 },
AndroidInfo: { Sound: 'default' }
}
});
}
// Also send via Web Push API for browser notifications
const subscriptions = await getWebPushSubscriptions(userIds);
for (const sub of subscriptions) {
await webPush.sendNotification(sub, JSON.stringify({ title, body, data }));
}
}
/**
* Cron job: Remind members about proposals ending soon
*/
export async function sendGovernanceReminders() {
const activeProposals = await getProposalsEndingSoon(24); // ending within 24 hours
for (const proposal of activeProposals) {
const eligibleVoters = await getEligibleVotersWhoHaventVoted(proposal.id);
await sendPushNotification({
userIds: eligibleVoters,
title: '⏰ Governance: Vote Closing Soon',
body: `Proposal #${proposal.id} "${proposal.title}" closes in 24 hours. Cast your vote!`,
data: {
action: 'OPEN_PROPOSAL',
proposalId: proposal.id,
channelId: proposal.channelId
}
});
}
}
/**
* Notify channel members when a voice room goes live
*/
export async function notifyVoiceRoomLive(
roomId: number,
roomTitle: string,
channelId: string
) {
const members = await getChannelMembers(channelId);
await sendPushNotification({
userIds: members,
title: '🎙️ Voice Room Live',
body: `"${roomTitle}" just started. Join the conversation!`,
data: {
action: 'JOIN_VOICE_ROOM',
roomId,
channelId
}
});
}Frontend: Register for Notifications
// src/services/notifications.ts
export async function initPushNotifications(walletAddress: string) {
// Request browser notification permission
if ('Notification' in window && Notification.permission === 'default') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
}
// Register service worker for background push
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/notification-sw.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.REACT_APP_VAPID_PUBLIC_KEY
});
// Send subscription to backend, linked to wallet address
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
walletAddress,
subscription
})
});
}
}
// Service worker handles notification clicks
// public/notification-sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
data: data.data
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { action, proposalId, roomId } = event.notification.data || {};
let url = '/';
if (action === 'OPEN_PROPOSAL') url = `/governance/${proposalId}`;
if (action === 'JOIN_VOICE_ROOM') url = `/voice/${roomId}`;
event.waitUntil(clients.openWindow(url));
});Step 8: NFT Membership Tiers
Create differentiated experiences based on NFT membership levels. This turns your free community app into a sustainable project with premium tiers—while still keeping base access open to anyone holding the governance token.
// src/services/membership.ts
import { ethers } from 'ethers';
interface MembershipTier {
id: number;
name: string;
perks: string[];
channels: string[];
voiceRoomAccess: boolean;
canPropose: boolean;
speakerPriority: number; // Higher = gets to speak first in voice rooms
}
const TIERS: MembershipTier[] = [
{
id: 0,
name: 'Community',
perks: ['General chat access', 'Public announcements'],
channels: ['general', 'introductions', 'off-topic'],
voiceRoomAccess: false,
canPropose: false,
speakerPriority: 0
},
{
id: 1,
name: 'Member',
perks: ['Alpha channels', 'Weekly AMA access', 'Custom avatar frame', 'Voice rooms'],
channels: ['general', 'introductions', 'alpha', 'research', 'ama'],
voiceRoomAccess: true,
canPropose: false,
speakerPriority: 1
},
{
id: 2,
name: 'OG',
perks: ['All Member perks', 'OG lounge', 'Direct team access', 'Governance proposals'],
channels: ['general', 'introductions', 'alpha', 'research', 'ama', 'og-lounge', 'team-chat'],
voiceRoomAccess: true,
canPropose: true,
speakerPriority: 2
},
{
id: 3,
name: 'Council',
perks: ['All access', 'Treasury oversight', 'Protocol decisions', 'Priority speaker'],
channels: ['*'], // All channels
voiceRoomAccess: true,
canPropose: true,
speakerPriority: 3
}
];
const MEMBERSHIP_NFT_ADDRESS = process.env.REACT_APP_MEMBERSHIP_NFT!;
/**
* Determine a user's membership tier based on their NFT holdings.
* Uses ERC-1155 where tokenId maps to tier level.
*/
export async function getMembershipTier(
walletAddress: string,
provider: ethers.Provider
): Promise<MembershipTier> {
const nftContract = new ethers.Contract(
MEMBERSHIP_NFT_ADDRESS,
[
'function balanceOf(address account, uint256 id) view returns (uint256)',
'function balanceOfBatch(address[] accounts, uint256[] ids) view returns (uint256[])'
],
provider
);
// Check all tiers in a single batch call
const addresses = TIERS.map(() => walletAddress);
const tierIds = TIERS.map(t => t.id);
const balances = await nftContract.balanceOfBatch(addresses, tierIds);
// Return highest tier the user qualifies for
for (let i = TIERS.length - 1; i >= 0; i--) {
if (balances[i] > 0n) return TIERS[i];
}
return TIERS[0]; // Default: Community tier (free access)
}
/**
* Check if user can access a specific channel based on their tier
*/
export function canAccessChannel(tier: MembershipTier, channelId: string): boolean {
if (tier.channels.includes('*')) return true;
return tier.channels.includes(channelId);
}This membership model enables a free community app at the base tier—anyone can join general chat—while premium tiers unlock alpha channels, voice rooms, and governance proposal rights. The NFT itself becomes tradable, creating a market-driven membership economy.
Step 9: Putting It All Together
Here's the main app component that ties all layers into a cohesive experience:
// src/App.tsx
import { useEffect, useState } from 'react';
import { useWallet } from './hooks/useWallet';
import { initChat } from './services/chat';
import { getMembershipTier, MembershipTier } from './services/membership';
import { initPushNotifications } from './services/notifications';
import { GatedChannel } from './components/auth/GatedChannel';
import { ChatChannel } from './components/chat/ChatChannel';
import { VoiceRoom } from './components/voice/VoiceRoom';
import { ProposalCard } from './components/governance/ProposalCard';
export default function App() {
const { session, provider, connect, disconnect } = useWallet();
const [tier, setTier] = useState<MembershipTier | null>(null);
const [activeView, setActiveView] = useState<'chat' | 'voice' | 'governance'>('chat');
const [activeChannel, setActiveChannel] = useState('general');
useEffect(() => {
if (!session || !provider) return;
async function bootstrap() {
// 1. Determine membership tier
const memberTier = await getMembershipTier(session!.address, provider!);
setTier(memberTier);
// 2. Initialize TRTC Chat
await initChat(session!.address, session!.userSig);
// 3. Register for push notifications
await initPushNotifications(session!.address);
}
bootstrap();
}, [session, provider]);
if (!session) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-white mb-4">Web3 Community</h1>
<p className="text-gray-400 mb-8">Connect your wallet to join the community</p>
<button
onClick={connect}
className="px-8 py-3 bg-purple-600 text-white rounded-xl text-lg font-medium hover:bg-purple-700"
>
Connect Wallet
</button>
</div>
</div>
);
}
return (
<div className="flex h-screen bg-gray-950">
{/* Sidebar */}
<aside className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
<div className="p-4">
<h1 className="text-xl font-bold text-white">YourDAO</h1>
<div className="mt-3 p-2 bg-gray-800 rounded-lg">
<p className="text-xs text-gray-400 font-mono">
{session.ensName || `${session.address.slice(0, 8)}...`}
</p>
{tier && (
<span className="text-xs text-purple-400 mt-1 block">{tier.name}</span>
)}
</div>
</div>
{/* Navigation */}
<nav className="px-3 space-y-1">
{['chat', 'voice', 'governance'].map(view => (
<button
key={view}
onClick={() => setActiveView(view as any)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm ${
activeView === view
? 'bg-purple-600/20 text-purple-300'
: 'text-gray-400 hover:bg-gray-800'
}`}
>
{view === 'chat' && '💬 '}
{view === 'voice' && '🎙️ '}
{view === 'governance' && '🗳️ '}
{view.charAt(0).toUpperCase() + view.slice(1)}
</button>
))}
</nav>
{/* Channel list */}
{tier && (
<div className="mt-4 px-3 flex-1 overflow-y-auto">
<p className="text-xs uppercase text-gray-600 mb-2 px-3">Channels</p>
{tier.channels.filter(c => c !== '*').map(channel => (
<button
key={channel}
onClick={() => { setActiveChannel(channel); setActiveView('chat'); }}
className={`w-full text-left px-3 py-1.5 rounded text-sm ${
activeChannel === channel
? 'text-white bg-gray-800'
: 'text-gray-400 hover:text-gray-200'
}`}
>
# {channel}
</button>
))}
</div>
)}
<div className="p-3 border-t border-gray-800">
<button onClick={disconnect} className="text-xs text-gray-500 hover:text-gray-300">
Disconnect
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 flex flex-col overflow-hidden">
{activeView === 'chat' && (
<GatedChannel channelId={activeChannel}>
<ChatChannel channelId={activeChannel} />
</GatedChannel>
)}
{activeView === 'voice' && (
<div className="p-6 space-y-4 overflow-y-auto">
<h2 className="text-2xl font-bold text-white">Voice Rooms</h2>
<VoiceRoom
roomId={10001}
channelid='governance-voice'
title="Weekly Governance Call"
description="Discuss active proposals with core contributors"
/>
<VoiceRoom
roomId={10002}
channelid='community-voice'
title="Open Mic — Community Hangout"
description="Casual conversation for all members"
/>
</div>
)}
{activeView === 'governance' && (
<div className="p-6 space-y-4 overflow-y-auto">
<h2 className="text-2xl font-bold text-white">Governance</h2>
<p className="text-gray-400">Active proposals from your DAO</p>
{/* ProposalCards rendered from contract data */}
</div>
)}
</main>
</div>
);
}Deploying Your MVP
Smart Contract Deployment
# Install Hardhat
npx hardhat init
# Compile contracts
npx hardhat compile
# Deploy to testnet first (Sepolia)
npx hardhat run scripts/deploy.ts --network sepolia
# Verify on Etherscan
npx hardhat verify --network sepolia <DEPLOYED_ADDRESS> <CONSTRUCTOR_ARGS>
# Deploy to mainnet (or L2 like Arbitrum/Base for lower gas)
npx hardhat run scripts/deploy.ts --network arbitrumBackend Deployment Checklist
| Component | Requirement |
|---|---|
| TRTC App ID | Register at trtc.io console |
| UserSig generation | Server-side only, never expose secret key |
| RPC URL | Alchemy/Infura for Ethereum, or chain-specific RPC |
| Cron jobs | Governance reminders, token balance cache refresh |
| Database | PostgreSQL for user profiles, channel metadata |
| Web Push | VAPID keys for browser notifications |
Frontend Deployment
npm run build
# Deploy to Vercel (fastest iteration)
vercel --prod
# Or deploy to IPFS for full decentralization
npx ipfs-deploy build/ -p pinata
# Or Cloudflare Pages for edge performance
npx wrangler pages deploy build/Scaling Considerations
As your in app community grows, TRTC scales with you:
| Scale | Members | Architecture |
|---|---|---|
| MVP | < 1,000 | Single community group, 1-2 voice rooms |
| Growth | 1,000–50,000 | Multiple topic channels, scheduled voice rooms, caching |
| Scale | 50,000+ | Regional groups, sharded topics, dedicated voice infrastructure |
TRTC Chat supports up to 100,000 members per Community group with topic-based threading. Voice rooms in live mode support 50 concurrent speakers and thousands of audience members. This means your infrastructure scales without architectural changes until you hit truly massive numbers.
Accelerate Development with TRTC MCP Server
If you use Claude Code or any MCP-compatible AI assistant during development, add the TRTC MCP server for contextual SDK assistance:
npx -y @anthropic-ai/claude-code mcp add trtc -- npx -y @anthropic-ai/mcp-remote https://mcp.trtc.io/sseWith the TRTC MCP server configured, your AI assistant can:
- Generate correct SDK initialization code for your exact use case
- Debug TRTC errors with access to the latest API reference
- Suggest optimal room configurations based on your community size
- Help implement advanced features (co-hosting, recording, screen sharing)
- Create and configure chat groups and voice rooms programmatically
This dramatically speeds up iteration when building and maintaining your community features.
Cost Analysis: Building a Free Community App
TRTC's free tier makes it viable to launch a free community app without upfront infrastructure costs:
| Component | Free Tier | Growth Tier |
|---|---|---|
| TRTC Chat | 100 DAU included | Pay-per-message beyond |
| TRTC Voice Room | 10,000 min/month free | $0.99 per 1,000 min |
| Smart Contract Deploy | ~$50 (Ethereum) / <$1 (L2) | Same |
| IPFS Storage | 1 GB free (Pinata) | $20/month for 100 GB |
| Backend Hosting | Free (Railway/Vercel) | $20+/month |
For most DAOs and NFT communities, the free tier covers several months of early operations. You can launch a fully functional free community app and upgrade only when usage demands it.
Security Best Practices
Web3 apps face unique attack vectors. Follow these practices:
- Never trust frontend verification alone — Always verify token balances server-side before granting TRTC group access or generating UserSigs
- Rate-limit UserSig generation — Prevent abuse by limiting signature requests per wallet per hour
- Use nonces for auth signatures — Prevent signature replay attacks with single-use nonces and expiration timestamps
- Audit smart contracts — Get CommunityGate and Governance contracts audited before mainnet (use Slither, Mythril for automated checks)
- Monitor token transfers — When a user sells their access token, revoke group membership via webhook listener on Transfer events
- Set short UserSig TTLs — 24-hour expiration with refresh on active sessions; revoke immediately on wallet disconnect
- Sanitize message content — Even with token-gating, implement content filtering to prevent spam and phishing links
What Makes This Different from Every Other Community App Guide
Most tutorials on building a community app focus on generic social features—user profiles, feeds, likes, comments. This guide exists because Web3 communities have fundamentally different needs:
- Cryptographic identity replaces email/password — No accounts to hack, no passwords to leak
- On-chain access control replaces admin invites — The smart contract is the bouncer, not a person
- Governance-native communication — Proposals get discussed and voted on in the same interface
- Production-quality voice — TRTC handles WebRTC complexity so you don't rebuild Clubhouse from scratch
- Token economics embedded in membership — Community growth directly benefits members through token value
- Permissionless and censorship-resistant — No platform can deplatform your community
The combination of TRTC's battle-tested communication infrastructure with smart contract access control delivers the best of both worlds: reliable real-time messaging and audio with trustless, verifiable membership.
Why TRTC Chat Powers the Largest Web3 Communities
The architecture above covers the integration mechanics, but the real question is: why does TRTC Chat keep winning Web3 community deployments at scale? It comes down to infrastructure capabilities that are hard to replicate and specifically aligned with how crypto communities actually operate.
Push Notifications with Smart Online Detection
Web3 communities run globally across every timezone. A governance vote announcement at 3am in your timezone shouldn't disappear into the void. TRTC IM solves this with automatic online status detection: users who are active receive in-app messages instantly, while offline users receive push notifications. You don't need to build your own presence or status system—the SDK handles this transparently.
The delivery scale matters for Web3. When a protocol needs to broadcast a critical governance vote or token launch alert, TRTC IM delivers 10 million messages in under 30 seconds. For time-sensitive events like governance deadlines or coordinated token migrations, this speed is the difference between quorum and a failed vote.
There's also a practical edge case that Web3 teams hit constantly: Android users without app store certificates. Many crypto community apps aren't listed on Google Play due to policy friction around token-related functionality. For these sideloaded installs, TRTC stores offline messages for 7 days (up to 20 messages per conversation). When users relaunch the app, those messages sync immediately—no messages lost, no manual polling required.
Community Groups That Scale to One Million Members
Most messaging infrastructure caps group size at 100K or 200K members. TRTC Chat supports a single community group with up to 1 million members and full message history. This unlocks an architecture pattern that large Web3 projects rely on: app-level mega communities where the entire token holder base exists in one searchable, message-complete group.
The practical deployment looks like this: a mega group for all-hands announcements and community-wide engagement, plus thousands of smaller interest groups run by KOLs (Key Opinion Leaders). KOLs use their groups to post live stream announcements, trading signals, exclusive market analysis, and order card links. When a KOL posts in a group, offline members receive a push notification—this single feature dramatically increases app open rates and daily active users because users return to the app on relevant, personalized triggers rather than generic marketing pushes.
For the user-facing experience, projects build a recommended groups feed where users discover and join groups based on their interests, token holdings, or trading activity. The combination of mega groups for broad reach and KOL micro-groups for high-engagement content mirrors how the most successful crypto communities actually structure their communication.
Real-World Deployment Pattern
One client (anonymized) began integrating TRTC Chat in late 2023 and scaled heavily through mid-2024. Their architecture demonstrates the pattern:
Mega groups (app-wide): Used for purchase guidance, official news broadcasts, and community-wide engagement campaigns. Offline push notifications drive re-engagement—when a user hasn't opened the app in 48 hours, the next mega group announcement brings them back. Red envelope gamification (crypto-native rewards) keeps the group active between announcements.
KOL groups (tens of thousands of groups): Each KOL manages their own group for live stream previews, market analysis posts, and order card sharing. The sheer quantity of groups—tens of thousands—creates a long-tail engagement surface where every user finds content relevant to their trading style or investment thesis.
Discovery layer: A recommended groups feed surfaces relevant communities to users. Combined with the push notification system, new users find their niche quickly, and dormant users re-engage through personalized KOL content rather than generic app alerts.
Global Ban and Super Admin Moderation
Web3 community moderation has a unique pain point: bad actors don't stay in one room. A scammer banned from your governance channel immediately moves to a KOL group or voice room to continue phishing. Traditional per-room bans create an endless game of whack-a-mole.
TRTC IM provides a global mute capability: banned users are silenced across every group and room for the specified ban duration. One moderation action covers the entire app. This is documented in the IM global mute API and can be triggered programmatically when your anti-spam system detects malicious behavior, or manually by super admins through your moderation dashboard.
For DAOs, this means a single governance vote to ban a bad actor actually enforces the ban universally—consistent with the community-governed moderation philosophy that Web3 communities expect.
Security for Recording and Moderation APIs
When integrating TRTC's recording and content moderation APIs, sensitive credentials (Access Key / Secret Key) are never transmitted in plaintext over the wire. The current integration flow has clients send credentials via secure email, and TRTC configures them internally on the server side. This eliminates the risk of AKSK interception during the setup process.
On the roadmap: console-based credential input and role-based authorization, which will let teams manage their own keys through the TRTC dashboard without any email-based exchange. For Web3 projects that take operational security seriously—which should be all of them—this approach ensures that even the infrastructure provider can't accidentally expose your moderation keys.
FAQ
What is a community app in Web3?
A Web3 community app is a communication platform where membership, access, and governance are controlled by blockchain tokens rather than centralized administrators. Members authenticate with crypto wallets, access is gated by on-chain token ownership, and community decisions happen through transparent on-chain voting. Unlike traditional platforms, members own their identity, data, and governance power.
Can I build a free community app with these tools?
Yes. TRTC offers a free tier that covers small-to-medium communities (100 DAU for chat, 10,000 voice minutes/month). Smart contract deployment costs vary by chain—Layer 2 networks like Arbitrum, Base, or Optimism cost under $1 to deploy. You can launch a fully functional community app at near-zero cost and only upgrade when your community outgrows the free tier.
How is this different from using Discord with Collab.Land?
Three fundamental differences: (1) You own the infrastructure and data—no platform risk of being banned. (2) Governance happens natively inside the app, not through external Snapshot links. (3) Voice quality uses TRTC's optimized infrastructure with proper anchor/audience role separation, instead of Discord's gaming-focused voice. Additionally, you fully customize the UX—NFT avatars, reputation badges, tiered access—without being constrained by Discord's feature set.
What blockchains work with token gating?
Any EVM-compatible chain works with the CommunityGate contract: Ethereum, Polygon, Arbitrum, Optimism, Base, BSC, Avalanche, and zkSync. For non-EVM chains like Solana or Aptos, adapt the verification middleware to use chain-specific RPC calls (e.g., @solana/web3.js for SPL token balance checks) instead of ethers.js.
How many people can join a TRTC voice room?
TRTC voice rooms in live mode support up to 50 concurrent anchors (speakers) with thousands of audience members listening simultaneously. For a DAO governance call, this means 50 people can actively debate while the entire community listens in—far exceeding what most DAOs need even at scale.
Do users need to pay gas to join channels?
No. Token verification is a read-only operation (view function on the smart contract), which costs zero gas. Users only pay gas when performing write operations: creating governance proposals or casting on-chain votes. For gasless voting, integrate Snapshot's off-chain voting system, which uses EIP-712 signatures instead of on-chain transactions.
Next Steps
You now have a complete blueprint for building a Web3 community app with token-gated chat, voice rooms, DAO governance, and NFT membership tiers. Here's a realistic 4-week roadmap:
Week 1: Deploy CommunityGate contract on testnet. Set up TRTC Chat SDK with basic group messaging. Implement wallet authentication flow.
Week 2: Build token-gated channel access with the GatedChannel component. Implement the voice room with anchor/audience roles using TRTC's live scene.
Week 3: Add governance contract and in-app voting UI. Set up push notifications for proposal deadlines and voice room events.
Week 4: NFT membership tier system. Polish UI/UX. Deploy contracts to mainnet (or L2). Launch to your alpha community.
Start building today:
- TRTC Web3 Solutions — Pre-built components for token-gated communication
- TRTC Chat SDK Documentation — Complete API reference for real-time messaging
- TRTC Voice Chat Room Guide — Voice room implementation with role management
The best community app is one your members actually use daily. Start with a single token-gated channel, one weekly governance voice room, and let your community tell you what to build next.


