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}