From f95e3cac5a051b580dac5f0bea176c38b17423af Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 7 Apr 2026 19:46:33 -0700 Subject: [PATCH 1/3] Finalize --- .../tan-query/comments/usePostTextUpdate.ts | 9 +- .../api/tan-query/utils/primeCommentData.ts | 1 - packages/common/src/utils/index.ts | 1 + packages/common/src/utils/videoUtils.ts | 69 ++++++ .../src/api/v1/models/comments.py | 2 + .../src/models/comments/comment.py | 1 + .../src/queries/comments/utils.py | 2 + .../tasks/entity_manager/entities/comment.py | 2 + .../components/PostUpdateCard.tsx | 224 +++++++++++++++++- .../components/TextPostCard.tsx | 82 +++++-- .../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/AttachVideoModal.tsx | 137 +++++++++++ .../components/PostUpdateCard.tsx | 123 +++++++++- .../components/TextPostCard.tsx | 78 +++++- 16 files changed, 698 insertions(+), 55 deletions(-) create mode 100644 packages/common/src/utils/videoUtils.ts create mode 100644 packages/web/src/pages/fan-club-detail-page/components/AttachVideoModal.tsx 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/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..55eca87eefc 100644 --- a/packages/discovery-provider/src/queries/comments/utils.py +++ b/packages/discovery-provider/src/queries/comments/utils.py @@ -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..9af66e87ddc 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py +++ b/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py @@ -241,6 +241,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 +250,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, 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 7c2e4e2380c..336dab2b69b 100644 --- a/packages/mobile/src/screens/coin-details-screen/components/PostUpdateCard.tsx +++ b/packages/mobile/src/screens/coin-details-screen/components/PostUpdateCard.tsx @@ -7,15 +7,38 @@ import { } from '@audius/common/api' import { useFeatureFlag } from '@audius/common/hooks' import { FeatureFlags } from '@audius/common/services' +import { + parseVideoUrl, + getVideoThumbnailUrl, + isValidVideoUrl +} from '@audius/common/utils' +import { Image, Modal, Pressable, View } from 'react-native' -import { Flex, Paper, Text } from '@audius/harmony-native' +import { + Button, + Flex, + IconClose, + IconPlay, + IconValidationCheck, + Paper, + PlainButton, + Text, + TextInput +} from '@audius/harmony-native' import { ComposerInput } from 'app/components/composer-input' import { Switch } from 'app/components/core' const messages = { postUpdate: 'Post Update', placeholder: 'Update your fans', - membersOnly: 'Members Only' + membersOnly: 'Members Only', + attachVideo: '+ Attach Video', + attachVideoTitle: 'ATTACH VIDEO', + attachVideoDescription: 'Add a YouTube or Vimeo link to attach it.', + videoUrlLabel: 'Video URL', + attachVideoHint: 'Tip: Unlisted videos work best for exclusive content.', + cancel: 'Cancel', + attach: 'Attach' } type PostUpdateCardProps = { @@ -25,6 +48,9 @@ type PostUpdateCardProps = { export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { 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 +60,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 +77,34 @@ 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 handleAttach = useCallback(() => { + if (!isAttachUrlValid) return + setVideoUrl(attachUrl.trim()) + setAttachUrl('') + setShowAttachModal(false) + }, [isAttachUrlValid, attachUrl]) + + const handleCloseModal = useCallback(() => { + setAttachUrl('') + setShowAttachModal(false) + }, []) + if (!isOwner || !isTextPostPostingEnabled) return null return ( @@ -74,13 +129,164 @@ 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} + + + + + + + + {/* Title + Divider */} + + + {messages.attachVideoTitle} + + + + + {/* Content */} + + + {messages.attachVideoDescription} + + + + + + {parsedAttachUrl ? ( + + + + ) : null} + + + {messages.attachVideoHint} + + + + {/* Actions */} + + + + + + + ) } 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/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 + })} + > +