Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/common/src/adapters/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,26 @@ export const notificationFromSDK = (
...formatBaseNotification(notification)
}
}
case 'track_collaborator_invite': {
const data = notification.actions[0].data

return {
type: NotificationType.TrackCollaboratorInvite,
trackId: HashId.parse(data.trackId)!,
inviterUserId: HashId.parse(data.inviterUserId)!,
...formatBaseNotification(notification)
}
}
case 'track_collaborator_accept': {
const data = notification.actions[0].data

return {
type: NotificationType.TrackCollaboratorAccept,
trackId: HashId.parse(data.trackId)!,
collaboratorUserId: HashId.parse(data.collaboratorUserId)!,
...formatBaseNotification(notification)
}
}
case 'comment': {
let entityId = 0
let entityType = Entity.Track
Expand Down
12 changes: 12 additions & 0 deletions packages/common/src/adapters/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ export const userTrackMetadataFromSDK = (
: null,
track_segments: input.trackSegments.map(trackSegmentFromSDK),
user,
// Accepted collaborator artists, decoded/cleaned like the owner.
collaborators: transformAndCleanList(input.collaborators, userMetadataFromSDK),
// Pending invites (owner-only); lets the edit form preserve them on save.
pending_collaborators: transformAndCleanList(
input.pendingCollaborators,
userMetadataFromSDK
),

// Retypes
license: (input.license as License) ?? null,
Expand Down Expand Up @@ -328,6 +335,11 @@ export const trackMetadataForUploadToSdk = (
}))
}
: undefined,
// Collaborators are tagged as full user objects in the form; the on-chain
// metadata carries numeric user ids, which the ETL reconciles into invites.
collaborators: input.collaborators
? input.collaborators.map((collaborator) => collaborator.user_id)
: undefined,
stemOf: input.stem_of
? {
category: input.stem_of.category,
Expand Down
2 changes: 2 additions & 0 deletions packages/common/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export * from './tan-query/tracks/useDeleteTrack'
export * from './tan-query/tracks/useDownloadTrackStems'
export * from './tan-query/tracks/useTrackDownloadCounts'
export * from './tan-query/tracks/useFavoriteTrack'
export * from './tan-query/tracks/useAcceptTrackCollaboration'
export * from './tan-query/tracks/useRejectTrackCollaboration'
export * from './tan-query/tracks/useToggleFavoriteTrack'
export * from './tan-query/tracks/useTrack'
export * from './tan-query/tracks/useTrackByParams'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Id } from '@audius/sdk'
import { useMutation, useQueryClient } from '@tanstack/react-query'

import { useQueryContext } from '~/api/tan-query/utils'
import { ID } from '~/models/Identifiers'

import { useCurrentUserId } from '../users/account/useCurrentUserId'

import { getTrackQueryKey } from './useTrack'

type AcceptTrackCollaborationArgs = {
trackId: ID
}

/**
* Accept a pending collaborator invite on a track. The current user is the
* invited collaborator; once accepted the track surfaces on their profile.
*/
export const useAcceptTrackCollaboration = () => {
const { audiusSdk } = useQueryContext()
const queryClient = useQueryClient()
const { data: currentUserId } = useCurrentUserId()

return useMutation({
mutationFn: async ({ trackId }: AcceptTrackCollaborationArgs) => {
if (!currentUserId) throw new Error('User ID is required')
const sdk = await audiusSdk()
await sdk.tracks.acceptTrackCollaboration({
userId: Id.parse(currentUserId),
trackId: Id.parse(trackId)
})
return { trackId }
},
onSuccess: ({ trackId }) => {
queryClient.invalidateQueries({ queryKey: getTrackQueryKey(trackId) })
}
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Id } from '@audius/sdk'
import { useMutation, useQueryClient } from '@tanstack/react-query'

import { useQueryContext } from '~/api/tan-query/utils'
import { ID } from '~/models/Identifiers'

import { useCurrentUserId } from '../users/account/useCurrentUserId'

import { getTrackQueryKey } from './useTrack'

type RejectTrackCollaborationArgs = {
trackId: ID
}

/**
* Decline a pending collaborator invite, or leave a track you've already
* accepted (remove yourself as a collaborator). Both map to the same on-chain
* Reject action, signed by the current user.
*/
export const useRejectTrackCollaboration = () => {
const { audiusSdk } = useQueryContext()
const queryClient = useQueryClient()
const { data: currentUserId } = useCurrentUserId()

return useMutation({
mutationFn: async ({ trackId }: RejectTrackCollaborationArgs) => {
if (!currentUserId) throw new Error('User ID is required')
const sdk = await audiusSdk()
await sdk.tracks.rejectTrackCollaboration({
userId: Id.parse(currentUserId),
trackId: Id.parse(trackId)
})
return { trackId }
},
onSuccess: ({ trackId }) => {
queryClient.invalidateQueries({ queryKey: getTrackQueryKey(trackId) })
}
})
}
8 changes: 8 additions & 0 deletions packages/common/src/models/Track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,14 @@ export type TrackMetadata = {
playlist_name: string
permalink: string
}

// Accepted collaborator artists (collaborative tracks). Embedded by the API,
// same shape as `user` (the track owner). Empty when there are none.
collaborators?: UserMetadata[]

// Pending collaborator invites — only populated by the API on the owner's own
// tracks, so the edit form can preserve still-pending invites on save.
pending_collaborators?: UserMetadata[]
} & Timestamped

export type WriteableTrackMetadata = TrackMetadata & {
Expand Down
5 changes: 5 additions & 0 deletions packages/common/src/schemas/upload/uploadFormSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ const createSdkSchema = () =>
.object({
track_id: z.optional(z.number()).nullable(),
allowed_api_keys: z.optional(z.array(z.string())).nullable(),
// Tagged collaborator artists (full user objects in the form; the upload
// adapter maps them to numeric ids for the on-chain metadata).
collaborators: z.optional(
z.array(z.object({ user_id: z.number() }).passthrough()).nullable()
),
description: z
.optional(z.string().max(MAX_DESCRIPTION_LENGTH))
.nullable(),
Expand Down
18 changes: 18 additions & 0 deletions packages/common/src/store/notifications/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export enum NotificationType {
USDCPurchaseBuyer = 'USDCPurchaseBuyer',
RequestManager = 'RequestManager',
ApproveManagerRequest = 'ApproveManagerRequest',
TrackCollaboratorInvite = 'TrackCollaboratorInvite',
TrackCollaboratorAccept = 'TrackCollaboratorAccept',
Comment = 'Comment',
CommentThread = 'CommentThread',
CommentMention = 'CommentMention',
Expand Down Expand Up @@ -85,6 +87,8 @@ export enum PushNotificationType {
MessageReaction = 'MessageReaction',
RequestManager = 'RequestManager',
ApproveManagerRequest = 'ApproveManagerRequest',
TrackCollaboratorInvite = 'TrackCollaboratorInvite',
TrackCollaboratorAccept = 'TrackCollaboratorAccept',
Comment = 'Comment',
CommentThread = 'CommentThread',
CommentMention = 'CommentMention',
Expand Down Expand Up @@ -548,6 +552,18 @@ export type ApproveManagerRequestNotification = BaseNotification & {
userId: ID
}

export type TrackCollaboratorInviteNotification = BaseNotification & {
type: NotificationType.TrackCollaboratorInvite
trackId: ID
inviterUserId: ID
}

export type TrackCollaboratorAcceptNotification = BaseNotification & {
type: NotificationType.TrackCollaboratorAccept
trackId: ID
collaboratorUserId: ID
}

export type CommentNotification = BaseNotification & {
type: NotificationType.Comment
entityId: ID
Expand Down Expand Up @@ -686,6 +702,8 @@ export type Notification =
| USDCPurchaseBuyerNotification
| RequestManagerNotification
| ApproveManagerRequestNotification
| TrackCollaboratorInviteNotification
| TrackCollaboratorAcceptNotification
| CommentNotification
| CommentThreadNotification
| CommentMentionNotification
Expand Down
3 changes: 2 additions & 1 deletion packages/common/src/store/ui/mobile-overflow-menu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export enum OverflowAction {
PLAY_NEXT = 'PLAY_NEXT',
ADD_TO_QUEUE = 'ADD_TO_QUEUE',
PLAY_COLLECTION_NEXT = 'PLAY_COLLECTION_NEXT',
ADD_COLLECTION_TO_QUEUE = 'ADD_COLLECTION_TO_QUEUE'
ADD_COLLECTION_TO_QUEUE = 'ADD_COLLECTION_TO_QUEUE',
LEAVE_TRACK_COLLABORATION = 'LEAVE_TRACK_COLLABORATION'
}

export enum OverflowSource {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const overflowRowConfig = ({
[OverflowAction.EDIT_TRACK]: { text: 'Edit Track' },
[OverflowAction.RELEASE_NOW]: { text: 'Release Now' },
[OverflowAction.DELETE_TRACK]: { text: 'Delete Track', isDestructive: true },
[OverflowAction.LEAVE_TRACK_COLLABORATION]: {
text: 'Remove Me as Collaborator',
isDestructive: true
},
[OverflowAction.VIEW_EPISODE_PAGE]: { text: 'View Episode Page' },
[OverflowAction.MARK_AS_PLAYED]: { text: 'Mark as Played' },
[OverflowAction.MARK_AS_UNPLAYED]: { text: 'Mark as Unplayed' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type React from 'react'
import {
useCollection,
useCurrentUserId,
useRejectTrackCollaboration,
useToggleFavoriteTrack,
useTrack,
useUser
Expand Down Expand Up @@ -63,7 +64,8 @@ const messages = {
markedAsPlayed: 'Marked as Played',
markedAsUnplayed: 'Marked as Unplayed',
willPlayNext: 'Will play next',
addedToQueue: 'Added to queue'
addedToQueue: 'Added to queue',
removedCollaboration: 'Removed as collaborator'
}

const TrackOverflowMenuDrawer = ({ render }: Props) => {
Expand Down Expand Up @@ -96,6 +98,8 @@ const TrackOverflowMenuDrawer = ({ render }: Props) => {
source: FavoriteSource.OVERFLOW
})

const { mutate: rejectTrackCollaboration } = useRejectTrackCollaboration()

const handlePurchasePress = useCallback(() => {
if (track?.track_id) {
openPremiumContentPurchaseModal(
Expand Down Expand Up @@ -215,6 +219,10 @@ const TrackOverflowMenuDrawer = ({ render }: Props) => {
})
)
},
[OverflowAction.LEAVE_TRACK_COLLABORATION]: () => {
rejectTrackCollaboration({ trackId: id })
toast({ content: messages.removedCollaboration })
},
[OverflowAction.MARK_AS_PLAYED]: () => {
dispatch(
setTrackPosition({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
IconUserFollowing
} from '@audius/harmony-native'
import { Text } from 'app/components/core'
import { CollaboratorLinks } from 'app/components/user-link'
import { UserBadges } from 'app/components/user-badges'
import { useIsUSDCEnabled } from 'app/hooks/useIsUSDCEnabled'
import { makeStyles, flexRowCentered, typography } from 'app/styles'
Expand Down Expand Up @@ -168,6 +169,10 @@ export const TrackDetailsTile = ({
<Flex row alignItems='center' gap='xs' mt='xs'>
<Text fontSize='medium'>{owner.name}</Text>
<UserBadges userId={owner.user_id} badgeSize='xs' />
<CollaboratorLinks
collaborators={track.collaborators}
badgeSize='xs'
/>
</Flex>
{earnAmount ? (
<Flex direction='row' alignItems='center' gap='xs' pt='xs'>
Expand Down
7 changes: 5 additions & 2 deletions packages/mobile/src/components/track/TrackCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
Paper,
Text
} from '@audius/harmony-native'
import { UserLink } from 'app/components/user-link'
import { TrackArtists } from 'app/components/user-link'
import { useNavigation } from 'app/hooks/useNavigation'

import { LockedStatusBadge } from '../core'
Expand Down Expand Up @@ -46,6 +46,7 @@ export const TrackCard = (props: TrackCardProps) => {
track,
'title',
'owner_id',
'collaborators',
'repost_count',
'save_count',
'is_unlisted',
Expand All @@ -58,6 +59,7 @@ export const TrackCard = (props: TrackCardProps) => {
const {
title,
owner_id,
collaborators,
repost_count,
save_count,
is_unlisted,
Expand Down Expand Up @@ -99,8 +101,9 @@ export const TrackCard = (props: TrackCardProps) => {
<Text variant='title' textAlign='center' numberOfLines={1}>
{title}
</Text>
<UserLink
<TrackArtists
userId={owner_id!}
collaborators={collaborators}
textAlign='center'
style={{ justifyContent: 'center' }}
/>
Expand Down
68 changes: 68 additions & 0 deletions packages/mobile/src/components/user-link/TrackArtists.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ComponentProps, Fragment } from 'react'

import type { ID } from '@audius/common/models'

import type { IconSize } from '@audius/harmony-native'
import { Flex, Text } from '@audius/harmony-native'

import { UserLink } from './UserLink'

type Collaborator = { user_id: ID }

type CollaboratorLinksProps = {
collaborators?: Collaborator[] | null
badgeSize?: IconSize
}

/**
* Renders accepted collaborators as comma-separated `", <UserLink>"` entries.
* Returns null when there are none, so it's a no-op append to an owner element.
*/
export const CollaboratorLinks = ({
collaborators,
badgeSize
}: CollaboratorLinksProps) => {
if (!collaborators?.length) {
return null
}
return (
<>
{collaborators.map((collaborator) => (
<Fragment key={collaborator.user_id}>
<Text color='subdued'>, </Text>
<UserLink userId={collaborator.user_id} badgeSize={badgeSize} />
</Fragment>
))}
</>
)
}

type TrackArtistsProps = ComponentProps<typeof UserLink> & {
/** Accepted collaborator artists embedded on the track. */
collaborators?: Collaborator[] | null
}

/**
* A track's artist line for mobile: the owner `<UserLink>` plus accepted
* collaborators. With the flag off (or no collaborators) it renders just the
* owner — a safe drop-in for an existing owner `<UserLink>`.
*/
export const TrackArtists = ({
collaborators,
...userLinkProps
}: TrackArtistsProps) => {
return (
<Flex
row
alignItems='center'
justifyContent='center'
style={{ flexShrink: 1 }}
>
<UserLink {...userLinkProps} />
<CollaboratorLinks
collaborators={collaborators}
badgeSize={userLinkProps.badgeSize}
/>
</Flex>
)
}
1 change: 1 addition & 0 deletions packages/mobile/src/components/user-link/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './UserLink'
export * from './TrackArtists'
Loading
Loading