diff --git a/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts b/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts index a0e115554b6..4aed18ed4fd 100644 --- a/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts +++ b/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts @@ -14,6 +14,7 @@ export type PostTextUpdateArgs = { body: string mint: string isMembersOnly?: boolean + videoUrl?: string } export const usePostTextUpdate = () => { @@ -31,12 +32,13 @@ export const usePostTextUpdate = () => { entityType: 'FanClub', body: args.body, mentions: [], - isMembersOnly: args.isMembersOnly ?? true + isMembersOnly: args.isMembersOnly ?? true, + videoUrl: args.videoUrl } as any }) }, onMutate: async (args: PostTextUpdateArgs & { newId?: ID }) => { - const { userId, body, entityId, mint, isMembersOnly } = args + const { userId, body, entityId, mint, isMembersOnly, videoUrl } = args const sdk = await audiusSdk() const newId = await sdk.comments.generateCommentId() args.newId = newId @@ -55,7 +57,8 @@ export const usePostTextUpdate = () => { replies: undefined, createdAt: new Date().toISOString(), updatedAt: undefined, - isMembersOnly: isMembersOnly ?? true + isMembersOnly: isMembersOnly ?? true, + videoUrl } // Prime the individual comment cache diff --git a/packages/common/src/api/tan-query/utils/primeCommentData.ts b/packages/common/src/api/tan-query/utils/primeCommentData.ts index 8cdc7bf282b..e914949a19b 100644 --- a/packages/common/src/api/tan-query/utils/primeCommentData.ts +++ b/packages/common/src/api/tan-query/utils/primeCommentData.ts @@ -17,7 +17,6 @@ export const primeCommentData = ({ }) => { // Populate individual comment cache comments.forEach((comment) => { - // Prime the main comment queryClient.setQueryData(getCommentQueryKey(comment.id), comment) // Prime any replies if they exist diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index bcd03d47ad7..05f64ef72ac 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -54,3 +54,4 @@ export * from './quickSearch' export * from './coinMetrics' export * from './convertHexToRGBA' export * from './socialLinks' +export * from './videoUtils' diff --git a/packages/common/src/utils/videoUtils.ts b/packages/common/src/utils/videoUtils.ts new file mode 100644 index 00000000000..f6c34c43ca8 --- /dev/null +++ b/packages/common/src/utils/videoUtils.ts @@ -0,0 +1,69 @@ +export type VideoPlatform = 'youtube' | 'vimeo' + +export type ParsedVideo = { + platform: VideoPlatform + videoId: string +} + +const YOUTUBE_REGEX = + /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/ + +const VIMEO_REGEX = /(?:vimeo\.com\/)(\d+)/ + +/** + * Parse a video URL and extract the platform and video ID. + * Supports YouTube and Vimeo URLs. + */ +export const parseVideoUrl = (url: string): ParsedVideo | null => { + const youtubeMatch = url.match(YOUTUBE_REGEX) + if (youtubeMatch) { + return { platform: 'youtube', videoId: youtubeMatch[1] } + } + + const vimeoMatch = url.match(VIMEO_REGEX) + if (vimeoMatch) { + return { platform: 'vimeo', videoId: vimeoMatch[1] } + } + + return null +} + +/** + * Get the thumbnail URL for a video. Only YouTube provides static thumbnail URLs. + * Vimeo requires an API call, so returns null. + */ +export const getVideoThumbnailUrl = ( + parsed: ParsedVideo +): string | null => { + if (parsed.platform === 'youtube') { + return `https://img.youtube.com/vi/${parsed.videoId}/hqdefault.jpg` + } + return null +} + +/** + * Get the embeddable URL for a video. + */ +export const getVideoEmbedUrl = (parsed: ParsedVideo): string => { + if (parsed.platform === 'youtube') { + return `https://www.youtube.com/embed/${parsed.videoId}` + } + return `https://player.vimeo.com/video/${parsed.videoId}` +} + +/** + * Get the watch URL for a video (for opening in a new tab). + */ +export const getVideoWatchUrl = (parsed: ParsedVideo): string => { + if (parsed.platform === 'youtube') { + return `https://www.youtube.com/watch?v=${parsed.videoId}` + } + return `https://vimeo.com/${parsed.videoId}` +} + +/** + * Check if a URL is a valid YouTube or Vimeo video URL. + */ +export const isValidVideoUrl = (url: string): boolean => { + return parseVideoUrl(url) !== null +} diff --git a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_fan_club_post.py b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_fan_club_post.py index 8144a118d23..4ce787beba0 100644 --- a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_fan_club_post.py +++ b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_fan_club_post.py @@ -9,6 +9,7 @@ from typing import List from sqlalchemy import text +from web3 import Web3 from web3.datastructures import AttributeDict from integration_tests.challenges.index_helpers import UpdateTask @@ -22,8 +23,6 @@ from src.tasks.entity_manager.entity_manager import entity_manager_update from src.utils.db_session import get_db -from web3 import Web3 - logger = logging.getLogger(__name__) # Base entities shared across fan club post (text post) tests. @@ -158,9 +157,9 @@ def test_create_fan_club_post(app, mocker): assert comments[0].entity_id == 1 assert comments[0].user_id == 1 - notifications = session.query(Notification).filter( - Notification.type == "comment" - ).all() + notifications = ( + session.query(Notification).filter(Notification.type == "comment").all() + ) assert len(notifications) == 0 @@ -169,12 +168,14 @@ def test_create_fan_club_post_invalid_no_artist_coin(app, mocker): Creating a fan club post for a user_id that has no artist coin should be silently skipped (validation error). """ - bad_metadata = json.dumps({ - "entity_id": 99, # user 99 doesn't exist and has no coin - "entity_type": "FanClub", - "body": "should fail", - "parent_comment_id": None, - }) + bad_metadata = json.dumps( + { + "entity_id": 99, # user 99 doesn't exist and has no coin + "entity_type": "FanClub", + "body": "should fail", + "parent_comment_id": None, + } + ) tx_receipts = { "BadFanClubPost": [ @@ -223,11 +224,13 @@ def test_fan_club_post_reply(app, mocker): ], } - reply_metadata = json.dumps({ - **fan_club_post_metadata, - "body": "replying to your post", - "parent_comment_id": 1, - }) + reply_metadata = json.dumps( + { + **fan_club_post_metadata, + "body": "replying to your post", + "parent_comment_id": 1, + } + ) tx_receipts = { "FanClubPostReply": [ @@ -283,12 +286,14 @@ def test_fan_club_post_reply_non_owner_rejected(app, mocker): ], } - cross_thread_metadata = json.dumps({ - "entity_id": 1, - "entity_type": "FanClub", - "body": "cross-thread reply", - "parent_comment_id": 1, # parent is a Track comment, not fan club - }) + cross_thread_metadata = json.dumps( + { + "entity_id": 1, + "entity_type": "FanClub", + "body": "cross-thread reply", + "parent_comment_id": 1, # parent is a Track comment, not fan club + } + ) tx_receipts = { "CrossThreadReply": [ @@ -537,11 +542,13 @@ def test_update_fan_club_post(app, mocker): ], } - update_metadata = json.dumps({ - "entity_id": 1, - "entity_type": "FanClub", - "body": "edited text", - }) + update_metadata = json.dumps( + { + "entity_id": 1, + "entity_type": "FanClub", + "body": "edited text", + } + ) tx_receipts = { "EditFanClubPost": [ @@ -578,11 +585,13 @@ def test_update_fan_club_post(app, mocker): def test_fan_club_post_with_mention(app, mocker): """Mentions in a fan club text post generate mention notifications.""" - mention_metadata = json.dumps({ - **fan_club_post_metadata, - "body": "hey @user3 check this out", - "mentions": [3], - }) + mention_metadata = json.dumps( + { + **fan_club_post_metadata, + "body": "hey @user3 check this out", + "mentions": [3], + } + ) tx_receipts = { "FanClubPostMention": [ @@ -629,12 +638,14 @@ def test_fan_club_post_and_track_comments_coexist(app, mocker): Creating both a fan club post and a Track comment in the same block produces the correct entity_type on each and independent notifications. """ - track_comment_metadata = json.dumps({ - "entity_id": 1, - "entity_type": "Track", - "body": "great track!", - "parent_comment_id": None, - }) + track_comment_metadata = json.dumps( + { + "entity_id": 1, + "entity_type": "Track", + "body": "great track!", + "parent_comment_id": None, + } + ) tx_receipts = { "FanClubPost": [ @@ -684,9 +695,9 @@ def test_fan_club_post_and_track_comments_coexist(app, mocker): assert track_comment.entity_id == 1 # Fan club owner self-post does not notify; track comment notifies track owner. - notifs = session.query(Notification).filter( - Notification.type == "comment" - ).all() + notifs = ( + session.query(Notification).filter(Notification.type == "comment").all() + ) assert len(notifs) == 1 assert notifs[0].group_id == "comment:1:type:Track" @@ -712,7 +723,9 @@ def _seed_coin_holders(session, artist_user_id=1, holder_user_ids=None, mint=COI session.flush() -def test_fan_club_text_post_notification_sent_to_followers_and_coin_holders(app, mocker): +def test_fan_club_text_post_notification_sent_to_followers_and_coin_holders( + app, mocker +): """ When an artist creates a root-level fan club text post, both followers and coin holders receive a fan_club_text_post notification (deduplicated). @@ -767,9 +780,7 @@ def test_fan_club_text_post_notification_sent_to_followers_and_coin_holders(app, # No "comment" notification since artist is posting on their own fan club comment_notifs = ( - session.query(Notification) - .filter(Notification.type == "comment") - .all() + session.query(Notification).filter(Notification.type == "comment").all() ) assert len(comment_notifs) == 0 @@ -835,11 +846,13 @@ def test_fan_club_text_post_notification_not_sent_for_replies(app, mocker): ], } - reply_metadata = json.dumps({ - **fan_club_post_metadata, - "body": "replying to my own post", - "parent_comment_id": 1, - }) + reply_metadata = json.dumps( + { + **fan_club_post_metadata, + "body": "replying to my own post", + "parent_comment_id": 1, + } + ) tx_receipts = { "ArtistReply": [ diff --git a/packages/discovery-provider/src/api/v1/models/comments.py b/packages/discovery-provider/src/api/v1/models/comments.py index b4535ca1941..cc0658f2fca 100644 --- a/packages/discovery-provider/src/api/v1/models/comments.py +++ b/packages/discovery-provider/src/api/v1/models/comments.py @@ -27,6 +27,7 @@ "is_artist_reacted": fields.Boolean(required=False), "created_at": fields.String(required=True), "updated_at": fields.String(required=False), + "video_url": fields.String(required=False), }, ) @@ -55,6 +56,7 @@ "created_at": fields.String(required=True), "updated_at": fields.String(required=False), "replies": fields.List(fields.Nested(reply_comment_model), require=True), + "video_url": fields.String(required=False), }, ) diff --git a/packages/discovery-provider/src/models/comments/comment.py b/packages/discovery-provider/src/models/comments/comment.py index 7ff66df5aac..4b1e1571ce9 100644 --- a/packages/discovery-provider/src/models/comments/comment.py +++ b/packages/discovery-provider/src/models/comments/comment.py @@ -21,6 +21,7 @@ class Comment(Base, RepresentableMixin): is_visible = Column(Boolean, default=True) is_edited = Column(Boolean, default=False) is_members_only = Column(Boolean, default=False, nullable=False) + video_url = Column(Text, nullable=True) 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/queries/comments/utils.py b/packages/discovery-provider/src/queries/comments/utils.py index f969c456c45..0db2812e4bc 100644 --- a/packages/discovery-provider/src/queries/comments/utils.py +++ b/packages/discovery-provider/src/queries/comments/utils.py @@ -2,12 +2,12 @@ from sqlalchemy import func +from src.models.comments.comment import FAN_CLUB_ENTITY_TYPE from src.models.comments.comment_mention import CommentMention from src.models.comments.comment_notification_setting import CommentNotificationSetting from src.models.comments.comment_reaction import CommentReaction from src.models.comments.comment_report import COMMENT_KARMA_THRESHOLD from src.models.moderation.muted_user import MutedUser -from src.models.comments.comment import FAN_CLUB_ENTITY_TYPE from src.models.users.aggregate_user import AggregateUser from src.models.users.user import User from src.queries.query_helpers import get_tracks, get_users @@ -145,6 +145,7 @@ def is_reacted(user_id, comment_id): "created_at": str(comment.created_at), "updated_at": str(comment.updated_at), "is_muted": is_muted if is_muted is not None else False, + "video_url": getattr(comment, "video_url", None), } # Check if we need to include replies (either explicitly provided or need to fetch them) @@ -878,6 +879,7 @@ def get_comment_replies( "created_at": str(reply.created_at), "updated_at": str(reply.updated_at), "is_muted": False, # Replies don't have mute status + "video_url": getattr(reply, "video_url", None), "is_artist_reacted": ( reactions_map.get((artist_id, reply.comment_id), False) if reactions_map 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 6e79dc32290..eba60a04928 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py +++ b/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py @@ -82,7 +82,9 @@ def validate_write_comment_tx(params: ManageEntityParameters): ) raw_entity_id = params.metadata.get("entity_id") if raw_entity_id is None: - raise IndexingValidationError("entity_id is required to create or update comment") + raise IndexingValidationError( + "entity_id is required to create or update comment" + ) track_entity_id = None coin_entity_id = None @@ -109,7 +111,10 @@ def validate_write_comment_tx(params: ManageEntityParameters): if params.action == Action.UPDATE: existing_c = params.existing_records[EntityType.COMMENT.value][comment_id] if existing_c.entity_type == EntityType.TRACK.value: - if entity_type_meta != EntityType.TRACK.value or track_entity_id != existing_c.entity_id: + if ( + entity_type_meta != EntityType.TRACK.value + or track_entity_id != existing_c.entity_id + ): raise IndexingValidationError( "Cannot change comment entity from metadata on update" ) @@ -241,6 +246,7 @@ def create_comment(params: ManageEntityParameters): if entity_type == FAN_CLUB_ENTITY_TYPE else False ) + video_url = metadata.get("video_url") comment_record = Comment( comment_id=comment_id, user_id=user_id, @@ -249,6 +255,7 @@ def create_comment(params: ManageEntityParameters): entity_id=stored_entity_id, track_timestamp_s=metadata["track_timestamp_s"], is_members_only=bool(is_members_only), + video_url=video_url if video_url else None, txhash=params.txhash, blockhash=params.event_blockhash, blocknumber=params.block_number, @@ -317,7 +324,9 @@ def create_comment(params: ManageEntityParameters): {"artist_user_id": entity_user_id}, ).fetchall() coin_holder_user_ids = {row[0] for row in coin_holder_rows} - recipient_user_ids = (follower_user_ids | coin_holder_user_ids) - {entity_user_id} + recipient_user_ids = (follower_user_ids | coin_holder_user_ids) - { + entity_user_id + } for recipient_id in recipient_user_ids: fan_club_notification = Notification( blocknumber=params.block_number, diff --git a/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx b/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx index cd7ed8681a5..d437960c8bc 100644 --- a/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx +++ b/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx @@ -123,9 +123,16 @@ const BecomeAMemberCard = ({ type FanClubHeroTileProps = { mint: string onPoweredByPress: () => void + isOwner: boolean + onUploadExclusive: () => void } -const FanClubHeroTile = ({ mint, onPoweredByPress }: FanClubHeroTileProps) => { +const FanClubHeroTile = ({ + mint, + onPoweredByPress, + isOwner, + onUploadExclusive +}: FanClubHeroTileProps) => { const { borderDefault } = useThemeColors() const { data: coin, isLoading } = useArtistCoin(mint) const ownerId = coin?.ownerId @@ -228,6 +235,17 @@ const FanClubHeroTile = ({ mint, onPoweredByPress }: FanClubHeroTileProps) => { {coin.description} ) : null} + + {isOwner ? ( + + ) : null} ) @@ -273,8 +291,6 @@ const FanClubFeed = ({ mint }: { mint: string }) => { - - {hasTextPosts ? textPosts.map((item) => ( { return ( - + - {/* Upload Exclusive Track - Artist only */} - {isOwner ? ( - - ) : null} + {membershipKnown ? : null} - {/* Membership CTA / leaderboard: wait until account + balance are known to avoid CLS */} + {/* Membership CTA: wait until account + balance are known to avoid CLS */} {!membershipKnown ? ( { const [messageId, setMessageId] = useState(0) const [isMembersOnly, setIsMembersOnly] = useState(true) + const [videoUrl, setVideoUrl] = useState() + const [showAttachModal, setShowAttachModal] = useState(false) + const [attachUrl, setAttachUrl] = useState('') const { data: currentUserId } = useCurrentUserId() const { data: coin } = useArtistCoin(mint) const { mutate: postTextUpdate } = usePostTextUpdate() @@ -34,6 +101,14 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { const isOwner = currentUserId != null && coin?.ownerId === currentUserId + const parsedVideo = videoUrl ? parseVideoUrl(videoUrl) : null + const thumbnailUrl = parsedVideo ? getVideoThumbnailUrl(parsedVideo) : null + + const parsedAttachUrl = attachUrl.trim() + ? parseVideoUrl(attachUrl.trim()) + : null + const isAttachUrlValid = isValidVideoUrl(attachUrl.trim()) + const handleSubmit = useCallback( (value: string) => { if (!value.trim() || !currentUserId || !coin?.ownerId) return @@ -43,13 +118,90 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { entityId: coin.ownerId, body: value.trim(), mint, - isMembersOnly + isMembersOnly, + videoUrl }) setMessageId((prev) => prev + 1) + setVideoUrl(undefined) }, - [currentUserId, coin?.ownerId, mint, postTextUpdate, isMembersOnly] + [ + currentUserId, + coin?.ownerId, + mint, + postTextUpdate, + isMembersOnly, + videoUrl + ] ) + const { height: windowHeight } = useWindowDimensions() + const { bottom: safeBottom } = useSafeAreaInsets() + const backdropAnim = useRef(new Animated.Value(0)).current + const sheetTranslateY = useRef(new Animated.Value(0)).current + + const backdropOpacity = backdropAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0, 0.5] + }) + + const dismissAttachModal = useCallback(() => { + Keyboard.dismiss() + Animated.parallel([ + Animated.timing(backdropAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true + }), + Animated.timing(sheetTranslateY, { + toValue: windowHeight, + duration: 220, + useNativeDriver: true + }) + ]).start(({ finished }) => { + if (finished) { + setAttachUrl('') + setShowAttachModal(false) + } + }) + }, [backdropAnim, sheetTranslateY, windowHeight]) + + useLayoutEffect(() => { + if (!showAttachModal) return + + backdropAnim.setValue(0) + sheetTranslateY.setValue(windowHeight) + + const openAnim = Animated.parallel([ + Animated.timing(backdropAnim, { + toValue: 1, + duration: 250, + useNativeDriver: true + }), + Animated.spring(sheetTranslateY, { + toValue: 0, + friction: 9, + tension: 65, + useNativeDriver: true + }) + ]) + + openAnim.start() + return () => { + openAnim.stop() + } + }, [showAttachModal, windowHeight, backdropAnim, sheetTranslateY]) + + const handleAttach = useCallback(() => { + if (!isAttachUrlValid) return + setVideoUrl(attachUrl.trim()) + setAttachUrl('') + dismissAttachModal() + }, [isAttachUrlValid, attachUrl, dismissAttachModal]) + + const handleCloseModal = useCallback(() => { + dismissAttachModal() + }, [dismissAttachModal]) + if (!isOwner || !isTextPostPostingEnabled) return null return ( @@ -74,13 +226,189 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { maxLength={2000} /> - - - {messages.membersOnly} - - + + + {videoUrl && parsedVideo ? ( + + + {thumbnailUrl ? ( + + ) : null} + + + + + setVideoUrl(undefined)} + style={{ + position: 'absolute', + top: 4, + left: 4, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.5)', + alignItems: 'center', + justifyContent: 'center' + }} + > + + + + ) : ( + setShowAttachModal(true)} + > + {messages.attachVideo} + + )} + + + + {messages.membersOnly} + + + + + + + + + + + + + + + + + {messages.attachVideoTitle} + + + + + + + {messages.attachVideoDescription} + + + + + + {parsedAttachUrl ? ( + + + + ) : null} + + + {messages.attachVideoHint} + + + + + + + + + + + + + ) } 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 b27e1889b78..6f4ed7e35d4 100644 --- a/packages/mobile/src/screens/coin-details-screen/components/TextPostCard.tsx +++ b/packages/mobile/src/screens/coin-details-screen/components/TextPostCard.tsx @@ -7,7 +7,13 @@ import { useArtistCoin } from '@audius/common/api' import type { ID } from '@audius/common/models' -import { getLargestTimeUnitText } from '@audius/common/utils' +import { + getLargestTimeUnitText, + parseVideoUrl, + getVideoEmbedUrl +} from '@audius/common/utils' +import { Pressable, View } from 'react-native' +import WebView from 'react-native-webview' import { Button, @@ -82,6 +88,9 @@ export const TextPostCard = ({ commentId, mint }: TextPostCardProps) => { const isLocked = comment.message === null + const parsedVideo = comment.videoUrl ? parseVideoUrl(comment.videoUrl) : null + const videoEmbedUrl = parsedVideo ? getVideoEmbedUrl(parsedVideo) : null + return ( { {isLocked ? ( - - - - - - {messages.membersOnly} - + <> + {parsedVideo ? ( + + + + + + ) : null} + + + + + - + ) : ( <> {comment.message} + {videoEmbedUrl ? ( + + + + ) : null} {comment.isMembersOnly !== false ? ( diff --git a/packages/mobile/src/screens/profile-screen/ProfileHeader/ProfileHeader.tsx b/packages/mobile/src/screens/profile-screen/ProfileHeader/ProfileHeader.tsx index 0e2793cfe5d..39d63bd68d0 100644 --- a/packages/mobile/src/screens/profile-screen/ProfileHeader/ProfileHeader.tsx +++ b/packages/mobile/src/screens/profile-screen/ProfileHeader/ProfileHeader.tsx @@ -20,7 +20,6 @@ import { BuyArtistCoinButton } from '../BuyArtistCoinButton' import { ProfileCoverPhoto } from '../ProfileCoverPhoto' import { ProfileInfo } from '../ProfileInfo' import { ProfileMetrics } from '../ProfileMetrics' -import { UploadTrackButton } from '../UploadTrackButton' import { ArtistProfilePicture } from './ArtistProfilePicture' import { Bio } from './Bio' @@ -151,9 +150,7 @@ export const ProfileHeader = memo(() => { )} - {isOwner ? ( - - ) : !isArtistCoinLoading && userId && artistCoin?.mint ? ( + {!isArtistCoinLoading && userId && artistCoin?.mint ? ( ) : null} diff --git a/packages/mobile/src/screens/profile-screen/UploadTrackButton.tsx b/packages/mobile/src/screens/profile-screen/UploadTrackButton.tsx deleted file mode 100644 index d8be8546027..00000000000 --- a/packages/mobile/src/screens/profile-screen/UploadTrackButton.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useCallback } from 'react' - -import { IconCloudUpload, Button } from '@audius/harmony-native' -import { useNavigation } from 'app/hooks/useNavigation' - -const messages = { - uploadTrack: 'Upload a Track' -} - -export const UploadTrackButton = () => { - const navigation = useNavigation() - - const handlePress = useCallback(() => { - navigation.push('Upload') - }, [navigation]) - - return ( - - ) -} diff --git a/packages/sdk/src/sdk/api/comments/CommentsAPI.ts b/packages/sdk/src/sdk/api/comments/CommentsAPI.ts index f5ee999e8eb..ec2b3fb1370 100644 --- a/packages/sdk/src/sdk/api/comments/CommentsAPI.ts +++ b/packages/sdk/src/sdk/api/comments/CommentsAPI.ts @@ -85,7 +85,8 @@ export class CommentsApi extends GeneratedCommentsApi { parentCommentId, trackTimestampS, entityId, - isMembersOnly + isMembersOnly, + videoUrl } = metadata const newCommentId = commentId ?? (await this.generateCommentId()) @@ -110,6 +111,9 @@ export class CommentsApi extends GeneratedCommentsApi { if (trackTimestampS !== undefined) { data.track_timestamp_s = trackTimestampS } + if (videoUrl !== undefined) { + data.video_url = videoUrl + } const res = await this.entityManager.manageEntity({ userId, entityType: EntityType.COMMENT, @@ -143,7 +147,8 @@ export class CommentsApi extends GeneratedCommentsApi { parentCommentId: md.parentId, trackTimestampS: md.trackTimestampS, mentions: md.mentions, - isMembersOnly: (md as any).isMembersOnly + isMembersOnly: (md as any).isMembersOnly, + videoUrl: (md as any).videoUrl }) } 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 30c5e59e8d4..8fc3ba362d8 100644 --- a/packages/sdk/src/sdk/api/comments/types.ts +++ b/packages/sdk/src/sdk/api/comments/types.ts @@ -54,7 +54,8 @@ export const CreateCommentSchema = z parentCommentId: z.optional(z.number()), trackTimestampS: z.optional(z.number()), mentions: z.optional(z.array(z.number())), - isMembersOnly: z.optional(z.boolean()) + isMembersOnly: z.optional(z.boolean()), + videoUrl: z.optional(z.string()) }) .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 4a287ceda51..4bf106bf037 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/Comment.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/Comment.ts @@ -147,11 +147,17 @@ export interface Comment { */ replies?: Array; /** - * + * * @type {number} * @memberof Comment */ parentCommentId?: number; + /** + * + * @type {string} + * @memberof Comment + */ + videoUrl?: string; } /** @@ -200,6 +206,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'], + 'videoUrl': !exists(json, 'video_url') ? undefined : json['video_url'], }; } @@ -231,6 +238,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, + 'video_url': value.videoUrl, }; } diff --git a/packages/web/src/pages/fan-club-detail-page/components/AttachVideoModal.tsx b/packages/web/src/pages/fan-club-detail-page/components/AttachVideoModal.tsx new file mode 100644 index 00000000000..a4fd2d0dfc7 --- /dev/null +++ b/packages/web/src/pages/fan-club-detail-page/components/AttachVideoModal.tsx @@ -0,0 +1,137 @@ +import { useCallback, useState } from 'react' + +import { isValidVideoUrl, parseVideoUrl } from '@audius/common/utils' +import { + Button, + Flex, + Hint, + IconValidationCheck, + Modal, + ModalContent, + ModalFooter, + ModalHeader, + ModalTitle, + Text, + TextInput +} from '@audius/harmony' + +const messages = { + title: 'ATTACH VIDEO', + description: 'Add a YouTube or Vimeo link to attach it.', + label: 'Video URL', + hint: 'Tip: Unlisted videos work best for exclusive content.', + cancel: 'Cancel', + attach: 'Attach' +} + +const YouTubeIcon = () => ( + + + + + +) + +const VimeoIcon = () => ( + + + + +) + +type AttachVideoModalProps = { + isOpen: boolean + onClose: () => void + onAttach: (videoUrl: string) => void +} + +export const AttachVideoModal = ({ + isOpen, + onClose, + onAttach +}: AttachVideoModalProps) => { + const [url, setUrl] = useState('') + + const parsed = url.trim() ? parseVideoUrl(url.trim()) : null + const isValid = isValidVideoUrl(url.trim()) + + const handleAttach = useCallback(() => { + if (!isValid) return + onAttach(url.trim()) + setUrl('') + onClose() + }, [isValid, url, onAttach, onClose]) + + const handleClose = useCallback(() => { + setUrl('') + onClose() + }, [onClose]) + + return ( + + + + + + + + {messages.description} + + + + setUrl(e.target.value)} + /> + + {parsed ? ( + + {parsed.platform === 'youtube' ? ( + + ) : ( + + )} + + + ) : null} + + {messages.hint} + + + + + + + + + + ) +} 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 ac23889a641..8431d8939cf 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 @@ -7,14 +7,26 @@ import { } from '@audius/common/api' import { useFeatureFlag } from '@audius/common/hooks' import { FeatureFlags } from '@audius/common/services' -import { Checkbox, Flex, Paper, Text } from '@audius/harmony' +import { parseVideoUrl, getVideoThumbnailUrl } from '@audius/common/utils' +import { + Checkbox, + Flex, + IconClose, + IconPlay, + Paper, + PlainButton, + Text +} from '@audius/harmony' import { ComposerInput } from 'components/composer-input/ComposerInput' +import { AttachVideoModal } from './AttachVideoModal' + const messages = { postUpdate: 'Post Update', placeholder: 'Update your fans', - membersOnly: 'Members Only' + membersOnly: 'Members Only', + attachVideo: '+ Attach Video' } type PostUpdateCardProps = { @@ -24,6 +36,8 @@ type PostUpdateCardProps = { export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { const [messageId, setMessageId] = useState(0) const [isMembersOnly, setIsMembersOnly] = useState(true) + const [videoUrl, setVideoUrl] = useState() + const [showAttachVideoModal, setShowAttachVideoModal] = useState(false) const { data: currentUserId } = useCurrentUserId() const { data: coin } = useArtistCoin(mint) const { mutate: postTextUpdate, isPending } = usePostTextUpdate() @@ -33,6 +47,9 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { const isOwner = currentUserId != null && coin?.ownerId === currentUserId + const parsedVideo = videoUrl ? parseVideoUrl(videoUrl) : null + const thumbnailUrl = parsedVideo ? getVideoThumbnailUrl(parsedVideo) : null + const handleSubmit = useCallback( (value: string) => { if (!value.trim() || !currentUserId || !coin?.ownerId) return @@ -42,11 +59,20 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { entityId: coin.ownerId, body: value.trim(), mint, - isMembersOnly + isMembersOnly, + videoUrl }) setMessageId((prev) => prev + 1) + setVideoUrl(undefined) }, - [currentUserId, coin?.ownerId, mint, postTextUpdate, isMembersOnly] + [ + currentUserId, + coin?.ownerId, + mint, + postTextUpdate, + isMembersOnly, + videoUrl + ] ) if (!isOwner || !isTextPostPostingEnabled) return null @@ -74,16 +100,89 @@ export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { blurOnSubmit /> - - - {messages.membersOnly} - - setIsMembersOnly((prev) => !prev)} - /> + + + {videoUrl && parsedVideo ? ( + ({ + position: 'relative', + width: 102, + height: 56, + borderRadius: theme.cornerRadius.s, + overflow: 'hidden', + backgroundColor: theme.color.neutral.n800, + cursor: 'pointer' + })} + > + {thumbnailUrl ? ( + + ) : null} + + + + setVideoUrl(undefined)} + css={(theme) => ({ + position: 'absolute', + top: 4, + left: 4, + width: 24, + height: 24, + borderRadius: theme.cornerRadius.circle, + backgroundColor: 'rgba(0,0,0,0.5)', + cursor: 'pointer', + '&:hover': { + backgroundColor: 'rgba(0,0,0,0.7)' + } + })} + > + + + + ) : ( + setShowAttachVideoModal(true)} + > + {messages.attachVideo} + + )} + + + + {messages.membersOnly} + + setIsMembersOnly((prev) => !prev)} + /> + + + setShowAttachVideoModal(false)} + onAttach={(url) => setVideoUrl(url)} + /> ) } 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 737f693bf62..e1c5c8c25f9 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 @@ -9,7 +9,11 @@ import { useArtistCoin } from '@audius/common/api' import { ID } from '@audius/common/models' -import { getLargestTimeUnitText } from '@audius/common/utils' +import { + getLargestTimeUnitText, + parseVideoUrl, + getVideoEmbedUrl +} from '@audius/common/utils' import { Button, Flex, @@ -153,6 +157,9 @@ export const TextPostCard = ({ commentId, mint }: TextPostCardProps) => { const isLocked = comment.message === null + const parsedVideo = comment.videoUrl ? parseVideoUrl(comment.videoUrl) : null + const videoEmbedUrl = parsedVideo ? getVideoEmbedUrl(parsedVideo) : null + const popupMenuItems = [ isOwner && { onClick: handleEdit, @@ -202,6 +209,29 @@ export const TextPostCard = ({ commentId, mint }: TextPostCardProps) => { > {generatePlaceholder(commentId)} + {parsedVideo ? ( + ({ + position: 'relative', + width: '100%', + aspectRatio: '480 / 264', + borderRadius: theme.cornerRadius.m, + overflow: 'hidden', + border: `1px solid ${theme.color.border.strong}`, + backgroundColor: theme.color.neutral.n200, + cursor: 'pointer' + })} + onClick={() => setShowUnlockModal(true)} + > + + + + + ) : null} { ) : ( - - {comment.message} - {comment.isEdited ? ( - - {' '} - {messages.edited} - + <> + + {comment.message} + {comment.isEdited ? ( + + {' '} + {messages.edited} + + ) : null} + + {videoEmbedUrl ? ( + ({ + position: 'relative', + width: '100%', + aspectRatio: '16 / 9', + borderRadius: theme.cornerRadius.m, + overflow: 'hidden', + border: `1px solid ${theme.color.border.strong}`, + backgroundColor: theme.color.neutral.n800 + })} + > +