diff --git a/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts b/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts
index 4a189282cb1..a0e115554b6 100644
--- a/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts
+++ b/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts
@@ -13,6 +13,7 @@ export type PostTextUpdateArgs = {
entityId: ID // artist user_id (coin owner)
body: string
mint: string
+ isMembersOnly?: boolean
}
export const usePostTextUpdate = () => {
@@ -29,12 +30,13 @@ export const usePostTextUpdate = () => {
entityId: args.entityId,
entityType: 'FanClub',
body: args.body,
- mentions: []
- }
+ mentions: [],
+ isMembersOnly: args.isMembersOnly ?? true
+ } as any
})
},
onMutate: async (args: PostTextUpdateArgs & { newId?: ID }) => {
- const { userId, body, entityId, mint } = args
+ const { userId, body, entityId, mint, isMembersOnly } = args
const sdk = await audiusSdk()
const newId = await sdk.comments.generateCommentId()
args.newId = newId
@@ -52,7 +54,8 @@ export const usePostTextUpdate = () => {
replyCount: 0,
replies: undefined,
createdAt: new Date().toISOString(),
- updatedAt: undefined
+ updatedAt: undefined,
+ isMembersOnly: isMembersOnly ?? true
}
// Prime the individual comment cache
diff --git a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_comment.py b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_comment.py
index 97caea56f98..487421495cc 100644
--- a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_comment.py
+++ b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_comment.py
@@ -2,6 +2,7 @@
import logging # pylint: disable=C0302
from typing import List
+from sqlalchemy import text as sa_text
from web3 import Web3
from web3.datastructures import AttributeDict
@@ -1735,3 +1736,201 @@ def test_comment_reaction_validation(app, mocker):
.all()
)
assert len(reaction_notifications) == 0
+
+
+fan_club_entities = {
+ "users": [
+ {"user_id": 1, "wallet": "user1wallet"},
+ {"user_id": 2, "wallet": "user2wallet"},
+ ],
+ "tracks": [{"track_id": 1, "owner_id": 1}],
+}
+
+
+def _seed_artist_coin(db, user_id):
+ """Create artist_coins table (if absent) and insert a row so fan club validation passes."""
+ with db.scoped_session() as session:
+ session.execute(
+ sa_text(
+ "CREATE TABLE IF NOT EXISTS artist_coins ("
+ " user_id integer PRIMARY KEY,"
+ " mint text NOT NULL,"
+ " decimals integer NOT NULL DEFAULT 6,"
+ " ticker text"
+ ")"
+ )
+ )
+ session.execute(
+ sa_text(
+ "INSERT INTO artist_coins (user_id, mint, decimals, ticker) "
+ "VALUES (:uid, :mint, 6, 'TST') ON CONFLICT DO NOTHING"
+ ),
+ {"uid": user_id, "mint": f"TestMint{user_id}"},
+ )
+
+
+def test_fan_club_comment_is_members_only_default(app, mocker):
+ """
+ Fan club comments default to is_members_only=True when the field is omitted.
+ """
+ fan_club_metadata = json.dumps(
+ {
+ "entity_id": 1,
+ "entity_type": "FanClub",
+ "body": "exclusive update",
+ }
+ )
+
+ tx_receipts = {
+ "CreateFanClubComment": [
+ {
+ "args": AttributeDict(
+ {
+ "_entityId": 100,
+ "_entityType": "Comment",
+ "_userId": 1,
+ "_action": "Create",
+ "_metadata": f'{{"cid": "", "data": {fan_club_metadata}}}',
+ "_signer": "user1wallet",
+ }
+ )
+ },
+ ],
+ }
+
+ db, index_transaction = setup_test(app, mocker, fan_club_entities, tx_receipts)
+ _seed_artist_coin(db, 1)
+
+ with db.scoped_session() as session:
+ index_transaction(session)
+
+ comment = session.query(Comment).filter(Comment.comment_id == 100).first()
+ assert comment is not None
+ assert comment.is_members_only is True
+ assert comment.entity_type == "FanClub"
+
+
+def test_fan_club_comment_is_members_only_false(app, mocker):
+ """
+ Fan club comments respect is_members_only=false from metadata.
+ """
+ fan_club_metadata = json.dumps(
+ {
+ "entity_id": 1,
+ "entity_type": "FanClub",
+ "body": "public announcement",
+ "is_members_only": False,
+ }
+ )
+
+ tx_receipts = {
+ "CreatePublicFanClubComment": [
+ {
+ "args": AttributeDict(
+ {
+ "_entityId": 101,
+ "_entityType": "Comment",
+ "_userId": 1,
+ "_action": "Create",
+ "_metadata": f'{{"cid": "", "data": {fan_club_metadata}}}',
+ "_signer": "user1wallet",
+ }
+ )
+ },
+ ],
+ }
+
+ db, index_transaction = setup_test(app, mocker, fan_club_entities, tx_receipts)
+ _seed_artist_coin(db, 1)
+
+ with db.scoped_session() as session:
+ index_transaction(session)
+
+ comment = session.query(Comment).filter(Comment.comment_id == 101).first()
+ assert comment is not None
+ assert comment.is_members_only is False
+ assert comment.entity_type == "FanClub"
+ assert comment.text == "public announcement"
+
+
+def test_fan_club_comment_is_members_only_true_explicit(app, mocker):
+ """
+ Fan club comments respect is_members_only=true from metadata.
+ """
+ fan_club_metadata = json.dumps(
+ {
+ "entity_id": 1,
+ "entity_type": "FanClub",
+ "body": "vip content",
+ "is_members_only": True,
+ }
+ )
+
+ tx_receipts = {
+ "CreateMembersOnlyComment": [
+ {
+ "args": AttributeDict(
+ {
+ "_entityId": 102,
+ "_entityType": "Comment",
+ "_userId": 1,
+ "_action": "Create",
+ "_metadata": f'{{"cid": "", "data": {fan_club_metadata}}}',
+ "_signer": "user1wallet",
+ }
+ )
+ },
+ ],
+ }
+
+ db, index_transaction = setup_test(app, mocker, fan_club_entities, tx_receipts)
+ _seed_artist_coin(db, 1)
+
+ with db.scoped_session() as session:
+ index_transaction(session)
+
+ comment = session.query(Comment).filter(Comment.comment_id == 102).first()
+ assert comment is not None
+ assert comment.is_members_only is True
+
+
+def test_track_comment_is_members_only_always_false(app, mocker):
+ """
+ Track comments always have is_members_only=False, even if metadata
+ contains is_members_only=true.
+ """
+ track_comment_metadata = json.dumps(
+ {
+ "entity_id": 1,
+ "entity_type": "Track",
+ "body": "great track",
+ "is_members_only": True, # should be ignored for track comments
+ }
+ )
+
+ tx_receipts = {
+ "CreateTrackComment": [
+ {
+ "args": AttributeDict(
+ {
+ "_entityId": 103,
+ "_entityType": "Comment",
+ "_userId": 2,
+ "_action": "Create",
+ "_metadata": f'{{"cid": "", "data": {track_comment_metadata}}}',
+ "_signer": "user2wallet",
+ }
+ )
+ },
+ ],
+ }
+
+ db, index_transaction = setup_test(app, mocker, fan_club_entities, tx_receipts)
+
+ with db.scoped_session() as session:
+ index_transaction(session)
+
+ comment = session.query(Comment).filter(Comment.comment_id == 103).first()
+ assert comment is not None
+ assert comment.is_members_only is False
+ assert comment.entity_type == "Track"
diff --git a/packages/discovery-provider/src/models/comments/comment.py b/packages/discovery-provider/src/models/comments/comment.py
index 5d9353f3bbe..7ff66df5aac 100644
--- a/packages/discovery-provider/src/models/comments/comment.py
+++ b/packages/discovery-provider/src/models/comments/comment.py
@@ -20,6 +20,7 @@ class Comment(Base, RepresentableMixin):
is_delete = Column(Boolean, default=False)
is_visible = Column(Boolean, default=True)
is_edited = Column(Boolean, default=False)
+ is_members_only = Column(Boolean, default=False, nullable=False)
txhash = Column(Text, nullable=False)
blockhash = Column(Text, nullable=False)
blocknumber = Column(Integer, ForeignKey("blocks.number"), nullable=False)
diff --git a/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py b/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py
index bf6f5d0bca4..046b885eb75 100644
--- a/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py
+++ b/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py
@@ -235,6 +235,12 @@ def create_comment(params: ManageEntityParameters):
).scalar()
comment_id = params.entity_id
+ # Only fan club posts can be members-only; track comments are never gated.
+ is_members_only = (
+ bool(metadata.get("is_members_only", True))
+ if entity_type == FAN_CLUB_ENTITY_TYPE
+ else False
+ )
comment_record = Comment(
comment_id=comment_id,
user_id=user_id,
@@ -242,6 +248,7 @@ def create_comment(params: ManageEntityParameters):
entity_type=entity_type,
entity_id=stored_entity_id,
track_timestamp_s=metadata["track_timestamp_s"],
+ is_members_only=bool(is_members_only),
txhash=params.txhash,
blockhash=params.event_blockhash,
blocknumber=params.block_number,
diff --git a/packages/mobile/src/screens/coin-details-screen/components/PostUpdateCard.tsx b/packages/mobile/src/screens/coin-details-screen/components/PostUpdateCard.tsx
index f9087adc388..022f345e306 100644
--- a/packages/mobile/src/screens/coin-details-screen/components/PostUpdateCard.tsx
+++ b/packages/mobile/src/screens/coin-details-screen/components/PostUpdateCard.tsx
@@ -9,6 +9,7 @@ import { useFeatureFlag } from '@audius/common/hooks'
import { FeatureFlags } from '@audius/common/services'
import { Flex, Paper, Text } from '@audius/harmony-native'
+import { Switch } from 'app/components/core'
import { ComposerInput } from 'app/components/composer-input'
const messages = {
@@ -23,6 +24,7 @@ type PostUpdateCardProps = {
export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => {
const [messageId, setMessageId] = useState(0)
+ const [isMembersOnly, setIsMembersOnly] = useState(true)
const { data: currentUserId } = useCurrentUserId()
const { data: coin } = useArtistCoin(mint)
const { mutate: postTextUpdate } = usePostTextUpdate()
@@ -40,11 +42,12 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => {
userId: currentUserId,
entityId: coin.ownerId,
body: value.trim(),
- mint
+ mint,
+ isMembersOnly
})
setMessageId((prev) => prev + 1)
},
- [currentUserId, coin?.ownerId, mint, postTextUpdate]
+ [currentUserId, coin?.ownerId, mint, postTextUpdate, isMembersOnly]
)
if (!isOwner || !isTextPostPostingEnabled) return null
@@ -75,6 +78,10 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => {
{messages.membersOnly}
+
diff --git a/packages/mobile/src/screens/coin-details-screen/components/TextPostCard.tsx b/packages/mobile/src/screens/coin-details-screen/components/TextPostCard.tsx
index ee3c294f4dc..b27e1889b78 100644
--- a/packages/mobile/src/screens/coin-details-screen/components/TextPostCard.tsx
+++ b/packages/mobile/src/screens/coin-details-screen/components/TextPostCard.tsx
@@ -131,7 +131,15 @@ export const TextPostCard = ({ commentId, mint }: TextPostCardProps) => {
{comment.message}
-
+
+ {comment.isMembersOnly !== false ? (
+
+
+
+ {messages.membersOnly}
+
+
+ ) : null}
0) {
data.mentions = mentions
}
@@ -138,7 +142,8 @@ export class CommentsApi extends GeneratedCommentsApi {
commentId: md.commentId,
parentCommentId: md.parentId,
trackTimestampS: md.trackTimestampS,
- mentions: md.mentions
+ mentions: md.mentions,
+ isMembersOnly: (md as any).isMembersOnly
})
}
return await this.createCommentWithEntityManager({
diff --git a/packages/sdk/src/sdk/api/comments/types.ts b/packages/sdk/src/sdk/api/comments/types.ts
index 633b8b2eba6..30c5e59e8d4 100644
--- a/packages/sdk/src/sdk/api/comments/types.ts
+++ b/packages/sdk/src/sdk/api/comments/types.ts
@@ -53,7 +53,8 @@ export const CreateCommentSchema = z
commentId: z.optional(z.number()),
parentCommentId: z.optional(z.number()),
trackTimestampS: z.optional(z.number()),
- mentions: z.optional(z.array(z.number()))
+ mentions: z.optional(z.array(z.number())),
+ isMembersOnly: z.optional(z.boolean())
})
.strict()
.refine(
diff --git a/packages/sdk/src/sdk/api/generated/default/models/Comment.ts b/packages/sdk/src/sdk/api/generated/default/models/Comment.ts
index 5dac1fb762b..d8159327103 100644
--- a/packages/sdk/src/sdk/api/generated/default/models/Comment.ts
+++ b/packages/sdk/src/sdk/api/generated/default/models/Comment.ts
@@ -141,11 +141,17 @@ export interface Comment {
*/
replies?: Array;
/**
- *
+ *
* @type {number}
* @memberof Comment
*/
parentCommentId?: number;
+ /**
+ * Whether this post is gated to coin holders only
+ * @type {boolean}
+ * @memberof Comment
+ */
+ isMembersOnly?: boolean;
}
/**
@@ -193,6 +199,7 @@ export function CommentFromJSONTyped(json: any, ignoreDiscriminator: boolean): C
'updatedAt': !exists(json, 'updated_at') ? undefined : json['updated_at'],
'replies': !exists(json, 'replies') ? undefined : ((json['replies'] as Array).map(ReplyCommentFromJSON)),
'parentCommentId': !exists(json, 'parent_comment_id') ? undefined : json['parent_comment_id'],
+ 'isMembersOnly': !exists(json, 'is_members_only') ? undefined : json['is_members_only'],
};
}
@@ -223,6 +230,7 @@ export function CommentToJSON(value?: Comment | null): any {
'updated_at': value.updatedAt,
'replies': value.replies === undefined ? undefined : ((value.replies as Array).map(ReplyCommentToJSON)),
'parent_comment_id': value.parentCommentId,
+ 'is_members_only': value.isMembersOnly,
};
}
diff --git a/packages/web/src/pages/fan-club-detail-page/components/PostUpdateCard.tsx b/packages/web/src/pages/fan-club-detail-page/components/PostUpdateCard.tsx
index 6e802279f61..ac23889a641 100644
--- a/packages/web/src/pages/fan-club-detail-page/components/PostUpdateCard.tsx
+++ b/packages/web/src/pages/fan-club-detail-page/components/PostUpdateCard.tsx
@@ -23,6 +23,7 @@ type PostUpdateCardProps = {
export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => {
const [messageId, setMessageId] = useState(0)
+ const [isMembersOnly, setIsMembersOnly] = useState(true)
const { data: currentUserId } = useCurrentUserId()
const { data: coin } = useArtistCoin(mint)
const { mutate: postTextUpdate, isPending } = usePostTextUpdate()
@@ -40,11 +41,12 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => {
userId: currentUserId,
entityId: coin.ownerId,
body: value.trim(),
- mint
+ mint,
+ isMembersOnly
})
setMessageId((prev) => prev + 1)
},
- [currentUserId, coin?.ownerId, mint, postTextUpdate]
+ [currentUserId, coin?.ownerId, mint, postTextUpdate, isMembersOnly]
)
if (!isOwner || !isTextPostPostingEnabled) return null
@@ -76,7 +78,10 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => {
{messages.membersOnly}
-
+ setIsMembersOnly((prev) => !prev)}
+ />
diff --git a/packages/web/src/pages/fan-club-detail-page/components/TextPostCard.tsx b/packages/web/src/pages/fan-club-detail-page/components/TextPostCard.tsx
index 42f126b0ba3..737f693bf62 100644
--- a/packages/web/src/pages/fan-club-detail-page/components/TextPostCard.tsx
+++ b/packages/web/src/pages/fan-club-detail-page/components/TextPostCard.tsx
@@ -263,6 +263,14 @@ export const TextPostCard = ({ commentId, mint }: TextPostCardProps) => {
{/* Footer: React count + Kebab menu */}
{!isLocked ? (
+ {comment.isMembersOnly !== false ? (
+
+
+
+ {messages.membersOnly}
+
+
+ ) : null}