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
+ })}
+ >
+
+
) : null}
-
+ >
)}
{/* Footer: React count + Kebab menu */}