Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions packages/web/examples/coin-gated/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# Coin-Gated Tracks (Web)
# Coin-Gated Content (Web)

Vite + React app that lets users browse and stream **coin-gated tracks** on Audius. Use this as a reference for:
Vite + React app that lets users browse and consume **coin-gated content** on Audius — both streaming tracks and members-only fan-club text posts. Use this as a reference for:

- **SDK setup** in a browser / Vite app (singleton, node polyfills)
- **Coin lookup** via `sdk.coins.getCoinByTicker()`
- **Coin-gated track listing** via `sdk.users.getTracksByUser()` with `gateCondition: ['token']`
- **Coin-gated text posts** via `sdk.comments.getFanClubFeed({ mint, userId })` (filtered to `item_type: 'text_post'`)
- **OAuth sign-in** (PKCE popup flow) for authenticated access checks
- **Solana wallet connection** via Phantom (`sdk.solanaWallet.auth()`)
- **Solana wallet connection** via Phantom (`sdk.solanaWallet.auth()`) — the SDK auto-injects `X-Solana-Wallet`/`X-Solana-Message`/`X-Solana-Signature` headers so the API can gate by holdings
- **Streaming gated tracks** via `sdk.tracks.streamTrack()`
- **Coin balance** via `sdk.users.getUserCoin()` / `sdk.wallets.getWalletCoins()`

Expand Down Expand Up @@ -54,6 +55,20 @@ Vite + React app that lets users browse and stream **coin-gated tracks** on Audi
| `src/config.ts` | Reads env vars (`VITE_AUDIUS_API_KEY`, `VITE_AUDIUS_ENVIRONMENT`, `VITE_DEFAULT_TICKER`). |
| `vite.config.ts` | React plugin + node polyfills (buffer, process) for SDK. |

## Verifying coin-gated text posts work via Solana wallet auth

The "Coin-Gated Text Posts" section calls `sdk.comments.getFanClubFeed({ mint, userId })` and shows whether each members-only post arrives decrypted (full message body) or tombstoned (locked).

To verify the wallet path end-to-end:

1. Browse a coin whose creator has at least one members-only fan-club text post (default `YAK`, override via `VITE_DEFAULT_TICKER`).
2. **Without** signing in or connecting a wallet (or with a wallet that holds none of the coin), members-only posts should show **Locked** with a placeholder body.
3. Click **Connect Solana Wallet** and approve the signature in Phantom. The connected wallet's `publicKey` is used to sign a one-shot `audius:solana-wallet:<timestamp>` message; the SDK middleware then attaches `X-Solana-Wallet` / `X-Solana-Message` / `X-Solana-Signature` to every subsequent request.
4. The post list refetches automatically (the React Query key includes the wallet pubkey). If the wallet holds the artist's coin, members-only posts now render with their full message body and an **Access granted** badge.
5. Disconnect the wallet — the credential is cleared (`sdk.solanaWallet.clearCredential()`) and gated posts return to **Locked**.

> Public posts (`isMembersOnly: false`) are always visible, regardless of auth state. Use them as a sanity check that the feed is loading.

## Keywords (for search / AI)

Coin-gated, token-gated, fan club, Phantom wallet, Solana wallet, OAuth, PKCE, streaming, getCoinByTicker, getTracksByUser, streamTrack, getUserCoin, getWalletCoins, Audius SDK, web example, React Query.
Coin-gated, token-gated, fan club, fan-club feed, text post, members-only, Phantom wallet, Solana wallet, OAuth, PKCE, streaming, getCoinByTicker, getTracksByUser, getFanClubFeed, streamTrack, getUserCoin, getWalletCoins, X-Solana-Wallet, Audius SDK, web example, React Query.
165 changes: 161 additions & 4 deletions packages/web/examples/coin-gated/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,29 @@ type TrackItem = {
isStreamGated?: boolean
}

type FanClubComment = {
id: string
userId?: string
message: string
createdAt: string
isMembersOnly?: boolean
isTombstone?: boolean
}

type FanClubUser = {
id?: string
handle?: string
name?: string
}

type FanClubFeedResponse = {
data: Array<
| { item_type: 'text_post'; comment: FanClubComment }
| { item_type: 'track'; track: TrackItem }
>
related: { users: FanClubUser[]; tracks: TrackItem[] }
}

// ---------------------------------------------------------------------------
// Phantom wallet detection
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -111,6 +134,64 @@ function useGatedTracks(artistId: string | undefined, userId: string | undefined
})
}

// ---------------------------------------------------------------------------
// Hooks: useFanClubPosts
// ---------------------------------------------------------------------------
//
// Fetches the fan-club feed for a coin mint. The API enforces the holder gate
// using whichever credentials the SDK has set:
// - OAuth user (sdk.oauth.login) -> gate via the user's linked wallets
// - Solana wallet (sdk.solanaWallet.auth) -> gate via X-Solana-* headers
//
// Members-only text posts are returned with their `message` populated when the
// caller holds the coin, and tombstoned (`isTombstone: true`, empty message)
// otherwise. Public posts (`isMembersOnly: false`) are always visible.

function useFanClubPosts(
coinMint: string | undefined,
userId: string | undefined,
walletConnected: boolean,
walletPubkey: string | null
) {
const sdk = getSDK()
return useQuery({
queryKey: [
'fan-club-posts',
coinMint,
userId,
walletConnected,
walletPubkey
],
queryFn: async () => {
const res = (await sdk.comments.getFanClubFeed({
mint: coinMint!,
userId,
sortMethod: 'newest'
})) as FanClubFeedResponse

const userById = new Map<string, FanClubUser>()
for (const u of res.related?.users ?? []) {
if (u.id) userById.set(u.id, u)
}

const posts = res.data
.filter(
(item): item is { item_type: 'text_post'; comment: FanClubComment } =>
item.item_type === 'text_post'
)
.map((item) => ({
...item.comment,
author: item.comment.userId
? userById.get(item.comment.userId)
: undefined
}))

return posts
},
enabled: !!coinMint
})
}

// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -144,6 +225,11 @@ export default function App() {

const coinMint = coin?.mint
const { data: coinBalance } = useCoinBalance(userId, walletPubkey ?? undefined, coinMint)
const {
data: posts,
isPending: postsPending,
error: postsError
} = useFanClubPosts(coinMint, userId, walletConnected, walletPubkey)

// -------------------------------------------------------------------------
// OAuth session restore
Expand Down Expand Up @@ -291,7 +377,7 @@ export default function App() {
return (
<div className='center'>
<div className='card'>
<h1 className='title'>Coin-Gated Tracks</h1>
<h1 className='title'>Coin-Gated Content</h1>
<p className='required'>Requires an Audius developer app API key.</p>
<p className='required'>
Create a <code>.env</code> file with:
Expand All @@ -315,10 +401,10 @@ export default function App() {
return (
<div className='container'>
{/* Header */}
<h1 className='title'>Coin-Gated Tracks</h1>
<h1 className='title'>Coin-Gated Content</h1>
<p className='subtitle'>
Browse and stream coin-gated tracks using an artist coin.
Sign in with Audius or connect a Solana wallet.
Browse coin-gated tracks and members-only fan-club text posts using an
artist coin. Sign in with Audius or connect a Solana wallet.
</p>

{/* Ticker input */}
Expand Down Expand Up @@ -492,6 +578,77 @@ export default function App() {
</div>
)}

{/* Coin-gated text posts */}
{coin && (
<div className='card'>
<p className='sectionTitle'>Coin-Gated Text Posts</p>
<p className='postsHint'>
Members-only posts are returned with their message body when the
caller holds the coin (via OAuth-linked wallet or connected Solana
wallet). Otherwise the API returns them tombstoned.
</p>

{postsPending ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<div className='spinner' />
</div>
) : postsError ? (
<p className='error'>
{postsError instanceof Error ? postsError.message : 'Failed to load posts'}
</p>
) : !posts || posts.length === 0 ? (
<p className='emptyState'>
No fan-club text posts found for ${coinTicker}.
</p>
) : (
<ul className='postList'>
{posts.map((post) => {
const membersOnly = !!post.isMembersOnly
const tombstoned = !!post.isTombstone
const decrypted = !tombstoned && post.message.length > 0
const accessGranted = !membersOnly || decrypted

return (
<li key={post.id} className='postItem'>
<div className='postHeader'>
<span className='postAuthor'>
@{post.author?.handle ?? 'unknown'}
</span>
<span className='postBadges'>
{membersOnly ? (
<span className='memberBadge'>Members-only</span>
) : (
<span className='publicBadge'>Public</span>
)}
<span
className={
accessGranted ? 'accessGranted' : 'accessDenied'
}
>
{accessGranted ? 'Access granted' : 'Locked'}
</span>
</span>
</div>
<p className='postBody'>
{decrypted ? (
post.message
) : (
<em className='postLocked'>
Locked – hold ${coinTicker} to view this post.
</em>
)}
</p>
<p className='postMeta'>
{new Date(post.createdAt).toLocaleString()}
</p>
</li>
)
})}
</ul>
)}
</div>
)}

{/* Error */}
{error && <p className='error'>{error}</p>}
</div>
Expand Down
80 changes: 80 additions & 0 deletions packages/web/examples/coin-gated/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,83 @@ body {
border-width: 2px;
border-top-color: #fff;
}

/* Fan-club text posts */
.postsHint {
font-size: 12px;
color: #777;
margin: 0 0 12px;
line-height: 1.5;
}

.postList {
list-style: none;
padding: 0;
margin: 0;
}

.postItem {
padding: 12px 0;
border-bottom: 1px solid #eee;
}

.postItem:last-child {
border-bottom: none;
}

.postHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}

.postAuthor {
font-size: 13px;
font-weight: 600;
color: #333;
}

.postBadges {
display: flex;
gap: 8px;
align-items: center;
font-size: 11px;
}

.memberBadge {
padding: 2px 8px;
background: #f0e6ff;
color: #6a1b9a;
border-radius: 10px;
font-weight: 600;
}

.publicBadge {
padding: 2px 8px;
background: #e0f0e0;
color: #2e7d32;
border-radius: 10px;
font-weight: 600;
}

.postBody {
font-size: 14px;
color: #1a1a1a;
margin: 0 0 4px;
white-space: pre-wrap;
word-break: break-word;
}

.postLocked {
color: #888;
font-style: italic;
}

.postMeta {
font-size: 11px;
color: #999;
margin: 0;
}
Loading