From 8a6e5595b2b7aa20419caeafb46742f9068abdd1 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Sat, 4 Apr 2026 16:31:51 -0700 Subject: [PATCH] Allow fan club text posts to be public (not members-only) Artists can now toggle the "Members Only" checkbox when creating fan club text posts. When unchecked, the post is visible to all users regardless of coin ownership. Existing fan club posts remain gated (is_members_only defaults to true for FanClub entity type in the indexer). - Add is_members_only column to comments table (default false for safety; indexer sets true explicitly for fan club posts) - Python indexer reads is_members_only from entity manager metadata - Go API includes is_members_only in FullComment and skips redaction for public posts - SDK and mutation layer pass isMembersOnly through create comment flow - Web: Checkbox is now toggleable (was checked+disabled) - Mobile: Added Switch toggle for Members Only - Both platforms show Members Only badge only when the post is gated - Tests for Go API (3 cases) and Python indexer (4 cases) Co-Authored-By: Claude Opus 4.6 --- .../tan-query/comments/usePostTextUpdate.ts | 11 +- .../tasks/entity_manager/test_comment.py | 199 ++++++++++++++++++ .../src/models/comments/comment.py | 1 + .../tasks/entity_manager/entities/comment.py | 7 + .../components/PostUpdateCard.tsx | 11 +- .../components/TextPostCard.tsx | 10 +- .../sdk/src/sdk/api/comments/CommentsAPI.ts | 9 +- packages/sdk/src/sdk/api/comments/types.ts | 3 +- .../api/generated/default/models/Comment.ts | 10 +- .../components/PostUpdateCard.tsx | 11 +- .../components/TextPostCard.tsx | 8 + 11 files changed, 266 insertions(+), 14 deletions(-) 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}